Wildcard CORS Risks and Safe Origin Allowlisting

When Access-Control-Allow-Origin: * is the only CORS header on a response, browser security boundaries degrade in ways that are not immediately visible in DevTools: credential flows break silently, shared CDN caches serve one tenant’s reflected origin to another, and public endpoints become readable by any script on the internet. This page covers the exact browser enforcement rules behind those failures and shows how to replace a wildcard with safe, auditable dynamic origin validation patterns — part of the broader Server-Side CORS Configuration & Header Management reference.


What the Fetch Standard Actually Says About Wildcards

The WHATWG Fetch Standard (section 3.2.5, “HTTP-no-credential flag”) defines three hard rules for the wildcard value:

  1. A response with Access-Control-Allow-Origin: * must not carry Access-Control-Allow-Credentials: true. If both headers are present, the browser rejects the response — no JavaScript sees the body, no error surfaces in fetch() rejection messages.
  2. The wildcard satisfies origin matching for all non-credentialed requests regardless of the actual request Origin, so the browser never sends the Origin value to the server for validation.
  3. Preflight responses bearing Access-Control-Allow-Origin: * are valid for non-credentialed requests but must not be accompanied by an Access-Control-Allow-Credentials: true header even in the preflight response itself.

These rules mean that * is not a shorthand for “allow everyone authenticated” — it is explicitly a “public, non-credentialed only” marker. Any endpoint that needs to share session data with a cross-origin caller must use exact-origin reflection instead.

Header Reference

Header Type Wildcard allowed With credentials CDN / cache interaction
Access-Control-Allow-Origin Response Yes — reflects * No — browser blocks Must pair with Vary: Origin when dynamically reflected
Access-Control-Allow-Credentials Response N/A Only with exact origin N/A for wildcard paths
Access-Control-Allow-Methods Response (preflight) * is not a valid value No Cached per URL + method tuple
Access-Control-Allow-Headers Response (preflight) * permitted (non-credentialed only) No Cached per URL + headers tuple
Access-Control-Max-Age Response (preflight) No No Browser cap: 7200 s (Chromium), 86400 s (Firefox)
Vary Response N/A N/A Prevents CDN serving wrong origin’s cached response

How Wildcard Failures Manifest

The diagram below traces the two main failure modes: the credential-block path and the cache-poisoning path.

Wildcard CORS failure modes Two swimlane flows showing: (1) browser blocking a credentialed request when Access-Control-Allow-Origin is wildcard, and (2) CDN cache poisoning when Vary: Origin is absent on dynamically reflected origins. FAILURE MODE 1 — Credential block FAILURE MODE 2 — Cache poisoning Browser sends fetch + credentials Server responds with Allow-Origin: * + Allow-Credentials: true Browser blocks response entirely JS sees network error, no body Fix: reflect exact validated origin — never combine * with Allow-Credentials WHATWG Fetch §3.2.5 step 7 Origin A request hits origin server Server reflects origin-a.com No Vary: Origin set CDN caches response with origin-a.com Origin B gets origin-a.com header Fix: emit Vary: Origin alongside dynamically reflected Access-Control-Allow-Origin CDN partitions cache per Origin value — cross-tenant leakage eliminated Both failures are invisible in browser panels when the blocked/cached response looks like a normal HTTP 200

Step-by-Step: Replacing a Wildcard with a Safe Allowlist

Step 1 — Audit existing wildcard usage

Before changing any code, identify every endpoint that emits Access-Control-Allow-Origin: *:

# Scan origin server access logs for wildcard responses (nginx/apache log format)
grep 'Access-Control-Allow-Origin: \*' /var/log/nginx/access.log | \
  awk '{print $7}' | sort | uniq -c | sort -rn | head -20
# Test a specific endpoint from an untrusted origin
curl -si -X OPTIONS https://api.example.com/v1/users \
  -H "Origin: https://attacker.test" \
  -H "Access-Control-Request-Method: POST" | \
  grep -i "access-control"

Cross-reference the list against endpoints that also handle session cookies or Authorization headers — those are the highest-priority replacements.

Step 2 — Build the allowlist

Collect distinct Origin header values from production request logs. Store them in an environment variable or configuration file rather than in source code so you can update them without a deploy:

# Extract unique origin values from nginx logs (last 7 days)
awk '$0 ~ /Origin:/ {match($0, /Origin: ([^ ]+)/, a); print a[1]}' \
  /var/log/nginx/access.log | sort -u

Step 3 — Nginx: dynamic regex validation

Replace any static add_header Access-Control-Allow-Origin '*' block with a validated map:

# /etc/nginx/conf.d/cors.conf
map $http_origin $cors_origin {
    default                              "";
    ~^https://app\.example\.com$        $http_origin;
    ~^https://admin\.example\.com$      $http_origin;
    ~^https://([a-z0-9-]+)\.trusted\.io$ $http_origin;
}

server {
    location /api/ {
        if ($request_method = 'OPTIONS') {
            add_header Access-Control-Allow-Origin  $cors_origin always;
            add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS' always;
            add_header Access-Control-Allow-Headers 'Authorization, Content-Type' always;
            add_header Access-Control-Max-Age        600 always;
            add_header Vary                          Origin always;
            return 204;
        }

        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Vary                        Origin        always;
        # When $cors_origin is empty (untrusted origin), no CORS header is emitted.
    }
}

The regex anchors (^ and $) and escaped dots (\.) are mandatory — without them platform.io would match platformXio.

Step 4 — Express/Node.js: exact-match Set lookup

An exact-match Set lookup is O(1) and sidesteps regex pitfall entirely:

// cors-middleware.js
const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://admin.example.com',
]);

function corsMiddleware(req, res, next) {
  const origin = req.headers.origin;

  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }
  // No ACAO header emitted for unrecognised origins.

  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
    res.setHeader('Access-Control-Max-Age', '600');
    return res.sendStatus(204);
  }

  next();
}

module.exports = corsMiddleware;

For endpoints that require credential sharing across subdomains, add Access-Control-Allow-Credentials: true only inside the if (origin && ALLOWED_ORIGINS.has(origin)) block — never outside it.

Step 5 — Phased rollout (audit mode first)

For legacy systems, deploy the allowlist in log-only mode before blocking:

// Audit mode: log mismatches, do not block
function corsMiddlewareAudit(req, res, next) {
  const origin = req.headers.origin;

  if (origin && !ALLOWED_ORIGINS.has(origin)) {
    console.warn(`[CORS-AUDIT] Unrecognised origin: ${origin}${req.path}`);
    // Still emit wildcard during audit to avoid breaking prod
    res.setHeader('Access-Control-Allow-Origin', '*');
  } else if (origin) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }

  next();
}

Monitor the audit logs for one to two weeks, add any legitimate origins to ALLOWED_ORIGINS, then flip to enforcement mode.


Edge Cases and Security Boundaries

Null origin from sandboxed iframes

<iframe sandbox> sends Origin: null. A wildcard does not match null — it sends null literally. A dynamic allowlist should never add 'null' to the allowed set; null is unauthenticated and untraceable, which makes it equivalent to an anonymous attacker origin. Reject it explicitly:

// null origin is NOT safe to reflect
if (origin && origin !== 'null' && ALLOWED_ORIGINS.has(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Vary', 'Origin');
}

Subdomain normalization and port matching

Browsers include the port in the Origin value when it differs from the scheme default (:443 for HTTPS, :80 for HTTP). https://app.example.com:8443 is a distinct origin from https://app.example.com. Ensure your allowlist entries — or regex patterns — explicitly account for non-standard ports.

Opaque responses and no-cors mode

fetch(url, { mode: 'no-cors' }) returns an opaque response regardless of Access-Control-Allow-Origin. The response body is inaccessible to JavaScript even if the server emits *. Opaque responses are used for fire-and-forget requests (analytics beacons, image preloads) where read access is not required — they are not a mechanism for bypassing CORS.

Preflight caching interaction with wildcard removal

When you change Access-Control-Allow-Origin: * to a dynamically reflected value, browsers that cached the preflight response under the wildcard will re-issue the preflight after Access-Control-Max-Age expires. To accelerate this during migration, temporarily set Access-Control-Max-Age: 0 so browsers do not reuse stale wildcard cache entries. Restore a longer value once dynamic reflection is confirmed stable.


Proxy and CDN Interaction

Vary: Origin is mandatory for shared caches

A CDN or reverse proxy that receives a response with Access-Control-Allow-Origin: https://app.example.com but no Vary: Origin header will cache that response keyed only on the URL. The next request from https://admin.example.com will receive the cached response with the wrong origin header — a cross-tenant CORS header leak. Understanding the Access-Control-* header directives explains how Vary fits into the broader header set.

Edge proxy origin rewriting

API gateways can rewrite CORS headers centrally, but this creates a maintenance risk: if the gateway emits Access-Control-Allow-Origin: * as a fallback when the upstream origin is unrecognised, it undermines the allowlist. Set the gateway’s fallback to emit no Access-Control-Allow-Origin header (i.e. deny by default), not a wildcard.

Cloudflare Workers, AWS Lambda@Edge, and similar edge runtimes are well-suited for centralising allowlist logic. The proxy bypass strategies reference covers how edge functions interact with the preflight cache.

Cache warming after allowlist migration

After deploying dynamic reflection with Vary: Origin, CDN caches that held the wildcard response must be purged. Most CDNs support tag-based or URL-based purge APIs. If you cannot purge immediately, temporarily lowering Access-Control-Max-Age (as described in cache duration tuning) forces earlier revalidation.


DevTools and curl Verification Checklist

# Confirm trusted origin is reflected correctly
curl -si -X OPTIONS https://api.example.com/v1/data \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET" | \
  grep -iE "(access-control|vary)"

# Confirm untrusted origin is rejected (no ACAO header in output)
curl -si -X OPTIONS https://api.example.com/v1/data \
  -H "Origin: https://attacker.test" \
  -H "Access-Control-Request-Method: GET" | \
  grep -iE "(access-control|vary)"

Common Mistakes

Issue Technical impact Mitigation
Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true Browser hard-blocks the response; fetch() rejects with a network error; no body or headers exposed Reflect the exact validated origin; never emit both headers simultaneously
Dynamic reflection without Vary: Origin CDN caches the first reflected origin value and serves it to all subsequent requesters, regardless of their actual origin Always add Vary: Origin alongside any dynamically reflected Access-Control-Allow-Origin
Regex pattern without anchors or escaped dots ~^https://platform.io$ matches https://platformXio if the dot is unescaped, or matches substrings if anchors are missing Anchor all patterns with ^ and $; escape literal dots as \.
Falling back to * in error handlers Allowlist validation errors silently degrade to wildcard behaviour, bypassing all restrictions on production endpoints Default to no Access-Control-Allow-Origin header on validation failure; log the error and return a 400 or 500
Adding 'null' to the allowlist Sandboxed iframes and data URLs all share the null origin; reflecting it grants any sandboxed page cross-origin read access Never allowlist the string null; reject it explicitly
Omitting Vary: Origin on the preflight response Some caches store the preflight separately; the missing Vary directive on OPTIONS responses causes incorrect cache hits for different origins’ preflights Set Vary: Origin on both preflight (OPTIONS) and actual responses

FAQ

Does Access-Control-Allow-Origin: * expose authenticated endpoints?

Not directly — the Fetch Standard forbids browsers from attaching cookies or Authorization headers to requests where Access-Control-Allow-Credentials is not explicitly true. The danger is developers who start with * and later add credential support: the moment Allow-Credentials: true appears alongside *, the browser blocks the response. The fix (reflecting the exact origin) then requires Vary: Origin, which is frequently overlooked. Starting with an explicit allowlist avoids this migration entirely.

Why does the browser block * combined with Allow-Credentials instead of just ignoring the credential?

The WHATWG Fetch Standard (section 3.2.5, step 7) treats the combination as a misconfiguration that must be rejected outright. The rationale is defence-in-depth: silently dropping the credential would permit partial data leakage through response headers or timing channels that do not themselves require authentication. An outright block forces the developer to make an explicit, auditable choice.

Can CDNs or reverse proxies fix a wildcard misconfiguration transparently?

Edge proxies can intercept and rewrite Access-Control-Allow-Origin before the response reaches the client. However, rewriting without Vary: Origin causes cache poisoning — the first reflected origin gets served to all subsequent requesters from a shared cache. Fix the origin server and add Vary: Origin; treat edge rewriting only as a temporary bridge during migration.

Is a regex-based origin allowlist safe enough, or should I use an exact-match list?

Regex allowlists are safe when the pattern is anchored (^ and $) and literal dots are escaped (\. not .). An unescaped dot matches any character, so ^https://platform.io$ also matches https://platformXio. An exact-match Set or array lookup avoids regex pitfalls entirely and is O(1) in most runtimes after JIT — prefer it unless you need genuine subdomain wildcarding.

What happens to preflight cache entries when I switch from wildcard to dynamic reflection?

Browsers key preflight cache entries on the request URL plus the method/headers tuple, not on the origin. When you add Vary: Origin to your CORS responses the browser’s own preflight cache is unaffected, but shared caches (CDNs, reverse proxies) partition stored responses by Origin header value. Existing wildcard cache entries age out after Access-Control-Max-Age seconds. During the transition, temporarily lower Max-Age to force faster re-validation.