CORS Security Auditing & Hardening

A CORS configuration is a trust boundary expressed in HTTP headers, and like any boundary it can be drawn too wide. This page is a systematic procedure for auditing an existing configuration against the WHATWG Fetch Standard — probing each endpoint for reflected-origin acceptance, null-origin trust, wildcard-plus-credentials, subdomain-suffix bypasses, insecure protocol reflection, over-broad Access-Control-Allow-Headers, and missing Vary: Origin — and then hardening it. It is part of Server-Side CORS Configuration & Header Management, and it builds directly on Wildcard Risks & Mitigation and Dynamic Origin Validation Patterns.

Without an audit, a CORS misconfiguration is invisible: the site works, browsers show no error, and DevTools looks clean — because the flaw only manifests when an attacker’s origin makes the request. The response headers that make your API safe are exactly the ones you never see from your own trusted origin.


Threat Model

The attacker in the CORS threat model controls a web page and can lure a victim (an authenticated user of your application) into visiting it. From that page they can issue fetch() requests to your API. The same-origin policy stops them from reading the responses — unless your CORS headers tell the browser to allow it. The audit asks one question of every endpoint: for which origins does this server instruct the browser to lift the same-origin read barrier, and does it do so with credentials attached?

Three properties define the blast radius of any finding:

A critical finding requires unvalidated (or bypassable) reflection and credentials. Everything else is lower severity but still worth fixing, because configurations drift and a benign wildcard today becomes a credentialed reflection tomorrow.


Misconfiguration Reference

Each row is a distinct check in the audit: the flaw, the attack it enables, and how to detect it from outside the server.

Misconfiguration Attack it enables Detection
Reflected Origin with no allowlist Any site reads responses; with credentials, reads per-user data Send Origin: https://evil.example; ACAO echoes it back
Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true Browser hard-blocks (defence works), but signals intent to relax validation Both headers present in one response; browser rejects
Reflected origin with credentials Credentialed cross-site read of authenticated data — the critical finding Untrusted Origin reflected and Allow-Credentials: true
Origin: null accepted Sandboxed iframe / data: payload reads the response Send Origin: null; ACAO returns null
Subdomain-suffix / substring match Lookalike domain (evil-example.com) passes validation Send Origin: https://evil-example.com; ACAO reflects it
Insecure protocol reflection http:// origin allowed; MITM injects malicious origin Send Origin: http://app.example.com; ACAO reflects it
Over-broad Access-Control-Allow-Headers: * with credentials Silent — * does not mean Authorization under credentials; masks intent Inspect preflight response; wildcard where explicit list expected
Missing Vary: Origin on reflected responses Shared-cache poisoning; wrong origin served cross-tenant Reflected ACAO present, Vary absent or missing Origin
Overly permissive Access-Control-Allow-Methods Enables state-changing verbs (DELETE, PUT) cross-origin Preflight returns broad method list for untrusted origin

The Audit Decision Tree

Run every endpoint through this tree. The path that ends in a red node is a finding; the green node is the only safe terminal state.

CORS audit decision tree A top-down decision tree. Starting from probing an endpoint with an untrusted Origin, it branches on whether the server reflects the origin, then whether credentials are enabled, then whether Vary: Origin is present, ending in critical, high, or safe outcomes. Probe endpoint with Origin: https://evil.example Is the untrusted Origin reflected in ACAO? no Fixed allowlist — no reflection finding yes Allow-Credentials: true also present? yes CRITICAL — credentialed cross-site read no Vary: Origin present on the response? no Cache-poisoning exposure yes HIGH — any origin reads non-credentialed responses Re-run each branch with Origin: null, a subdomain-suffix lookalike, and an http:// origin. Any reflection of those variants is a finding regardless of credentials.
Figure — Endpoint triage: reflection gates the finding, credentials and Vary set its severity.

Step-by-Step Audit Procedure

Step 1 — Enumerate every CORS-emitting surface

CORS headers can be added at the edge (CDN, WAF), the proxy (Nginx, HAProxy), or the application. Duplicate or conflicting layers are themselves a finding. List every host and path that could emit Access-Control-*, then confirm which layer is authoritative:

# Baseline: what does a plain request return, with no Origin at all?
curl -si https://api.example.com/v1/account | grep -iE '^(access-control|vary)'

An endpoint that emits Access-Control-Allow-Origin even when no Origin header is sent is statically configured — often with a wildcard — and should be checked first.

Step 2 — Probe for unvalidated reflection

Send an Origin the server must never trust. If it comes back, the server reflects blindly:

# Reflected-origin probe. A safe server does NOT echo evil.example.
curl -si https://api.example.com/v1/account \
  -H "Origin: https://evil.example" \
  | grep -iE '^(access-control-allow-origin|access-control-allow-credentials)'

If the output contains access-control-allow-origin: https://evil.example, note the endpoint. If it also contains access-control-allow-credentials: true, escalate it to critical immediately — this is a credentialed cross-site read, the highest-severity CORS finding. The distinction between the two is exactly the subject of Wildcard vs Dynamic Origin Reflection.

Step 3 — Probe the weak-matching variants

Blind reflection is the obvious case. Weak validation logic fails more subtly. Test the three classic bypasses:

# 1) null origin (sandboxed iframe, data: URL)
curl -si https://api.example.com/v1/account -H "Origin: null" \
  | grep -i access-control-allow-origin

# 2) subdomain-suffix lookalike (endsWith/includes bug)
curl -si https://api.example.com/v1/account -H "Origin: https://evil-example.com" \
  | grep -i access-control-allow-origin

# 3) attacker-controlled subdomain of a lookalike
curl -si https://api.example.com/v1/account -H "Origin: https://example.com.attacker.io" \
  | grep -i access-control-allow-origin

# 4) insecure protocol downgrade
curl -si https://api.example.com/v1/account -H "Origin: http://app.example.com" \
  | grep -i access-control-allow-origin

Any of these being reflected indicates a matching bug rather than an intentional allow. These map directly to the validation rules covered in Origin Matching Rules & Validation.

Step 4 — Audit the preflight surface

The preflight response reveals which methods and headers are permitted, and whether Access-Control-Allow-Headers is over-broad. Simulate a preflight with OPTIONS:

curl -si -X OPTIONS https://api.example.com/v1/account \
  -H "Origin: https://evil.example" \
  -H "Access-Control-Request-Method: DELETE" \
  -H "Access-Control-Request-Headers: Authorization, X-Custom" \
  | grep -iE '^(access-control|vary)'

Flag an over-broad Access-Control-Allow-Methods (state-changing verbs offered to an untrusted origin) and a wildcard Access-Control-Allow-Headers: *. Note that under credentials the * in Allow-Headers is not a wildcard — it matches the literal header name * — so a credentialed endpoint that relies on it is also silently broken. See Access-Control Header Directives for the exact semantics.

Step 5 — Confirm Vary: Origin on every reflected response

Any endpoint that reflects a validated origin must also emit Vary: Origin, or a shared cache will serve one origin’s header to another:

curl -si https://api.example.com/v1/account -H "Origin: https://app.example.com" \
  | grep -iE '^(access-control-allow-origin|vary)'

A reflected Access-Control-Allow-Origin without a matching Vary: Origin is a cache-poisoning finding, detailed in Handling Vary: Origin Header Correctly.

Step 6 — Automate across the full endpoint list

Fold the probes into one script so the audit is repeatable in CI and re-runnable after every deploy:

#!/usr/bin/env bash
# cors-audit.sh — probe a list of endpoints for common CORS flaws.
# Usage: ./cors-audit.sh endpoints.txt
set -euo pipefail

EVIL="https://evil.example"
declare -a PROBES=("$EVIL" "null" "https://evil-example.com" "http://app.example.com")

while IFS= read -r url; do
  [[ -z "$url" || "$url" == \#* ]] && continue
  echo "== $url =="
  for origin in "${PROBES[@]}"; do
    resp=$(curl -sS -m 10 -D - -o /dev/null "$url" -H "Origin: $origin" || true)
    acao=$(printf '%s' "$resp" | grep -i '^access-control-allow-origin:' | tr -d '\r' | awk '{print $2}')
    acac=$(printf '%s' "$resp" | grep -i '^access-control-allow-credentials:' | tr -d '\r' | awk '{print $2}')
    vary=$(printf '%s' "$resp" | grep -i '^vary:' | grep -io 'origin' || true)

    if [[ -n "$acao" ]]; then
      if [[ "$acao" == "$origin" || "$acao" == "*" ]]; then
        sev="REFLECTED"
        [[ "$acac" == "true" ]] && sev="CRITICAL (credentialed)"
        [[ -z "$vary" && "$acao" != "*" ]] && sev="$sev + no-Vary"
        printf '  [%s] origin=%-30s -> ACAO=%s\n' "$sev" "$origin" "$acao"
      fi
    fi
  done
done < "$1"

Feed it a newline-delimited endpoints.txt. The script only reports probes that were actually reflected, so a clean run produces no CRITICAL or REFLECTED lines. Wire it into a pipeline stage that fails the build on any critical line.


Edge Cases & Security Boundaries

The null origin is not a safe allowlist entry. Sandboxed iframes (<iframe sandbox>), documents from data: and file: URLs, and some cross-origin redirect chains all send Origin: null. Because an attacker can trivially produce null by hosting a payload in a sandboxed iframe, matching it is equivalent to allowing everyone. A hardened server rejects null explicitly and never adds the literal string to an allowlist.

Subdomain normalization must respect boundaries. origin.endsWith('.example.com') accepts https://evil-example.com; origin.includes('example.com') accepts https://example.com.attacker.io. Only exact comparison against an allowlist, or an anchored pattern such as ^https://([a-z0-9-]+\.)?example\.com$ with the dot escaped, matches a real subdomain boundary. This is the core of Dynamic Origin Validation Patterns.

Protocol and port are part of the origin. http://app.example.com, https://app.example.com, and https://app.example.com:8443 are three distinct origins. An allowlist that ignores scheme lets a man-in-the-middle on a plaintext connection inject an allowed origin; one that ignores port conflates a dev server with production.

Preflight caching extends the blast radius of a mistake. A permissive preflight response cached under a long Access-Control-Max-Age keeps authorizing the real request until it expires, even after you fix the server. During remediation, drop Access-Control-Max-Age so browsers revalidate — see Cache Duration Tuning & Max-Age.


Proxy & CDN Interaction

CORS headers added at more than one layer are a recurring source of both bugs and vulnerabilities. If Nginx adds Access-Control-Allow-Origin and the application does too, browsers reject the response for having multiple values — but worse, a proxy fallback that emits a wildcard when the upstream origin is unrecognised silently defeats an application-level allowlist. Set the edge and proxy to emit no Access-Control-Allow-Origin on a non-match (deny by default), never a wildcard.

Centralising CORS at the edge — a Cloudflare Worker, Lambda@Edge, or an API gateway — makes the allowlist auditable in one place, and the Proxy Bypass Strategies reference covers how edge functions interact with the preflight cache. Wherever the header is set, confirm Vary: Origin survives to the client so shared caches partition correctly.


Verification Checklist


Common Mistakes

Issue Technical impact Mitigation
Reflecting Origin with credentials and no allowlist Any website performs credentialed reads of authenticated data Validate against an exact allowlist; reflect only on a match
Treating Access-Control-Allow-Origin: * as the worst case Effort spent on * while the graver reflected-credentials flaw is missed Prioritise unvalidated reflection + credentials in triage
Allowlisting the string null Sandboxed iframes and data: URLs gain cross-origin access Reject null explicitly; never store it in the allowlist
endsWith / includes origin checks Lookalike domains bypass validation Use exact match or an anchored, dot-escaped regex
Reflecting without Vary: Origin Shared caches serve one origin’s ACAO to another Emit Vary: Origin on every reflected response
Wildcard Access-Control-Allow-Headers on credentialed endpoints * matches the literal header *, not Authorization; requests silently blocked or intent obscured List permitted headers explicitly when credentials are used
CORS set at two layers Duplicate headers rejected by browser; proxy wildcard fallback defeats app allowlist Make one layer authoritative; deny by default at the others

FAQ

What is the single most dangerous CORS misconfiguration?

Reflecting the request Origin unconditionally while also sending Access-Control-Allow-Credentials: true. This combination lets any website on the internet make credentialed cross-origin requests to your API and read the authenticated responses, because the browser trusts the reflected origin as an explicit allow. It is functionally equivalent to disabling the same-origin policy for your endpoint. A bare Access-Control-Allow-Origin: * is far less severe because the Fetch Standard forbids it from being combined with credentials, so cookies and Authorization headers are never attached.

How do I test for a reflected-origin vulnerability with curl?

Send a request with an Origin header set to a domain you control that the server should never trust, for example Origin: https://evil.example, and inspect the response. If Access-Control-Allow-Origin comes back echoing https://evil.example, the server reflects any origin. If Access-Control-Allow-Credentials: true also appears, the finding is critical. A safe server either omits Access-Control-Allow-Origin entirely for the untrusted origin or returns a fixed allowlisted value that does not match your probe.

Why does a missing Vary: Origin header count as a security issue?

When a server reflects a validated origin but omits Vary: Origin, any shared cache such as a CDN or reverse proxy stores the response keyed only on the URL. The first allowed origin’s Access-Control-Allow-Origin value is then served to every subsequent requester, including origins that should have been denied. This is cache poisoning: an attacker who warms the cache from an allowed origin can cause the wrong header to be served, and a legitimate user from a different origin can have their request silently broken. Vary: Origin forces the cache to partition entries per origin value.

Is accepting Origin: null ever safe?

No. The null origin is sent by sandboxed iframes, documents loaded from data: and file: URLs, and some redirect chains. It is unauthenticated and unattributable, so any attacker can produce it by loading their payload inside a sandboxed iframe. Adding the literal string null to an allowlist grants those contexts cross-origin access. A hardened configuration never reflects or matches null, and a credentialed endpoint must reject it outright.

Can a suffix match like endsWith('.example.com') be exploited?

Yes. A naive check such as origin.endsWith('.example.com') matches https://evil-example.com because the substring appears at the end without a boundary, and origin.includes('example.com') matches https://example.com.attacker.io. Both let an attacker register a lookalike domain and pass validation. Use exact string comparison against an allowlist, or an anchored regular expression with the leading dot and escaped separators, so that only a genuine subdomain boundary matches.


Topics in This Section