Wildcard vs Dynamic Origin Reflection: When to Use Each

Two server responses grant cross-origin access: a static Access-Control-Allow-Origin: *, and a dynamically reflected origin echoed back after validation. Choosing the wrong one either breaks credentialed requests or opens the API to every website on the internet. The decision is not a matter of taste — it follows directly from whether the endpoint handles credentials or caller-scoped data.

This page is part of CORS Security Auditing & Hardening, which covers the full process of finding and closing CORS misconfigurations.

The Decision in One Table

Scenario Correct choice Why
Public fonts, CDN assets, open read-only data, no credentials Access-Control-Allow-Origin: * Response is identical for all origins; simplest and most cacheable
Authenticated API using cookies or Authorization Reflect the validated origin + Allow-Credentials: true Wildcard is forbidden with credentials; only listed origins may read
Response contains per-tenant or per-user data (even without auth) Reflect the validated origin Limits which origins can read caller-scoped information
Multiple known front-end origins (app, admin, staging) Reflect the validated origin from an allowlist One wildcard cannot distinguish trusted from untrusted callers
Unknown / arbitrary origins with credentials Neither — reject Reflecting unvalidated origins is a credential-exfiltration vector

Root Cause: Why the Choice Is Forced

The WHATWG Fetch Standard (§3.2.3) makes one combination illegal: Access-Control-Allow-Origin: * cannot appear with Access-Control-Allow-Credentials: true. The browser checks this at response evaluation and blocks the read. So any endpoint that needs cookies or tokens cannot use a wildcard — it must echo the exact origin. Conversely, an endpoint that is genuinely public and credential-free gains nothing from reflection and is simpler and more cache-friendly with a wildcard, because the response body and headers are identical for every caller. The mechanics of this prohibition are detailed in Understanding Access-Control-Allow-Credentials.

Because the rule is evaluated by the browser rather than the server, the combinations form a small truth table — and only one row of it survives both credential modes:

Allow-Origin value against credentials mode Three rows for a wildcard, an exactly echoed origin, and an absent header, crossed with requests that omit credentials and requests that include them. The wildcard passes only without credentials, the exact echo passes in both modes, and an absent header always blocks. Value the server puts in Access-Control-Allow-Origin Request credentials mode omit or same-origin Request credentials mode include * static wildcard READ ALLOWED any origin, anonymous data BLOCKED wildcard with credentials is illegal https://app.example.com exact echo after a match READ ALLOWED only the matched origin READ ALLOWED cookies and Authorization flow (header absent) origin not on the list BLOCKED no allow header to compare BLOCKED no allow header to compare Only the middle row works in both modes, which is why credentialed APIs must echo

Prerequisite State

Step-by-Step: Pick and Implement

Step 1 — Classify the endpoint

Ask two questions: does it carry credentials, and does it return caller-specific data? A “yes” to either rules out the wildcard.

Step 2a — Public + credential-free → wildcard

location /public/ {
  add_header Access-Control-Allow-Origin "*" always;
  # No Allow-Credentials, no Vary: Origin needed — response is identical for all
}

Step 2b — Credentialed or scoped → validated reflection

Match the origin against an allowlist and reflect only the matched value, adding Vary: Origin so caches key per origin:

const ALLOWED = new Set([
  'https://app.example.com',
  'https://admin.example.com',
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');
  }
  next();
});

For the allowlist architecture and subdomain-safe matching, see Dynamic Origin Validation Patterns.

The important property of that middleware is that a single deployment answers three different callers three different ways, computing the header per request rather than storing it in the configuration:

One allowlist, three per-caller answers Three rows, each a caller origin passing through the allowlist membership test into a response header block. The app and admin origins are matched and receive their own origin echoed with credentials and Vary. The attacker origin fails the test and receives no access-control headers. request carries https://app.example.com ALLOWED.has() true access-control-allow-origin: https://app.example.com access-control-allow-credentials: true vary: origin request carries https://admin.example.com ALLOWED.has() true access-control-allow-origin: https://admin.example.com access-control-allow-credentials: true vary: origin request carries https://attacker.example ALLOWED.has() false no access-control headers are written at all the browser blocks the read with the standard message The header is computed per request, which is precisely why Vary: Origin is mandatory

Step 3 — Make the choice cache-safe

A wildcard response is byte-identical for every caller, so a shared cache can store one copy and replay it indefinitely; that cacheability is most of the wildcard’s appeal. A reflected origin is the opposite. The response now varies with a request header, and the only thing that tells a CDN so is Vary: Origin. Emit it on every reflected response — the preflight and the actual one — and emit it on the deny path too, because a cache that stored the allowed variant will otherwise replay that variant to an origin you deliberately rejected. The mechanics of that header, including how to merge it with an existing Vary value, are covered in Handling Vary: Origin Header Correctly.

Two caveats bite in production. First, Vary multiplies cache cardinality by the number of distinct Origin values the edge observes, and any third party can inflate that number at will simply by sending junk origins; for credentialed responses prefer Cache-Control: private, no-store over relying on Vary to keep tenants apart. Second, several CDNs honour Vary only for a small set of headers — Accept-Encoding in particular — and quietly ignore it for Origin. On those platforms, fold the origin into the cache key explicitly or mark reflected responses uncacheable at the edge; the pattern for doing that inside an edge function is shown in Handling CORS Preflight in Cloudflare Workers.

What the shared cache stores for a reflected origin Two panels compare a shared cache without Vary Origin, where a single entry keyed on the URL alone hands one origin's allow header to a different origin, with a cache that honours Vary Origin and stores a separate variant per origin value. Reflected origin, no Vary key: GET /v1/me stored acao = https://app.example.com https://admin.example.com now asks for /v1/me and is handed the app.example.com allow header Admin is blocked, and whichever origin warms the entry first decides for everyone Reflected origin with Vary: Origin key: GET /v1/me + app.example.com acao = https://app.example.com key: GET /v1/me + admin.example.com acao = https://admin.example.com Every caller receives the allow header that was computed for its own origin A wildcard needs no Vary because the response never differs; a reflected origin always does

Step 4 — Deny by omission, never by wildcard fallback

When the incoming Origin is not on the list, the correct response is to send no Access-Control-Allow-Origin at all. A surprising number of configurations instead fall back to * on a non-match, on the theory that a wildcard is the safe default. It is not. On a credential-free endpoint the fallback hands the response body to the very origin you meant to reject, which is the whole flaw you were trying to avoid; on a credentialed endpoint it converts a clean, well-understood denial into a wildcard-plus-credentials rejection whose console message points developers at the wrong header. Deny by omission, keep the status code and body unchanged, and let the browser produce its standard blocked-by-CORS message.

Verification

# Public endpoint: wildcard is fine
curl -si https://api.example.com/public/data -H "Origin: https://anything.example" | grep -i access-control-allow-origin
# → access-control-allow-origin: *

# Credentialed endpoint: exact reflection, and deny for unknown origins
curl -si https://api.example.com/v1/me -H "Origin: https://app.example.com" | grep -i access-control
# → access-control-allow-origin: https://app.example.com  +  access-control-allow-credentials: true
curl -si https://api.example.com/v1/me -H "Origin: https://attacker.example" | grep -i access-control-allow-origin
# → (no header — correctly denied)

Security Boundary Note

Never reflect the Origin header without validating it against an allowlist. Unvalidated reflection behaves like a wildcard that also works with credentials, letting any website read authenticated responses — a direct data-exfiltration and CSRF-amplification vector. If you find yourself reaching for Access-Control-Allow-Origin: * on an endpoint that uses credentials, the answer is not a wildcard and not blind reflection; it is a validated allowlist. Deeper threat modelling is in Wildcard Risks & Mitigation.

Edge Cases the Decision Table Does Not Cover

Origin: null belongs on neither side of the choice. Sandboxed iframes, data: and file: documents, and some redirect chains all send the literal string null. It identifies nothing and anyone can produce it, so adding it to an allowlist is strictly worse than a wildcard: it grants credentialed access to contexts you cannot attribute. If a sandboxed integration genuinely needs the API, give it a real origin by adding allow-same-origin to the sandbox attribute, or move that traffic to a token-authenticated server-to-server call.

The header carries exactly one value. Access-Control-Allow-Origin: https://app.example.com, https://admin.example.com is not a list — the browser compares the whole string against the request origin, fails, and blocks. Two Access-Control-Allow-Origin headers on one response fail the same way. Whichever branch you take, exactly one value must leave the server, which is also why stacking a proxy rule on top of application middleware breaks working configurations.

The other wildcards die under credentials too. When Access-Control-Allow-Credentials: true is present, * loses its wildcard meaning everywhere, not just in Access-Control-Allow-Origin. A preflight answering Access-Control-Allow-Headers: * is read as permitting a header literally named *, so a request carrying Authorization is rejected; the same applies to Access-Control-Allow-Methods: * and to Access-Control-Expose-Headers: *, which will silently hide every non-safelisted response header from your JavaScript. Choosing reflection therefore commits you to enumerating those lists explicitly.

Public does not always mean wildcard. An endpoint can be unauthenticated and still return caller-scoped data — a per-tenant configuration document fetched by tenant subdomain, for instance. There are no cookies to protect, but a wildcard still lets any page on the internet read another tenant’s document. Reflection with an allowlist is the right choice whenever the response body differs by who is asking, regardless of whether credentials are involved.

Redirects can erase the origin. If a cross-origin request is redirected to a different origin, the browser sets Origin: null on the redirected request. An allowlist that only knows your front ends will correctly deny it, which looks like a CORS bug but is actually a redirect problem — fix the redirect, do not widen the allowlist.

Framework Gotchas

Express with the cors package. cors({ origin: true }) reflects whatever Origin arrives. That is blind reflection, not an allowlist, and combined with credentials: true it is the exact critical finding this page exists to prevent. Pass an array of exact strings, or a function that consults your allowlist and calls back with an error for anything else.

Spring Framework. Since 5.3, calling allowedOrigins("*") together with allowCredentials(true) throws at startup rather than shipping an unusable policy. The replacement is allowedOriginPatterns(...), which matches the incoming origin against a pattern and then echoes the matched value, so the response carries an exact origin rather than a wildcard.

ASP.NET Core. Chaining AllowAnyOrigin() with AllowCredentials() throws InvalidOperationException when the policy is built, for the same reason. Use SetIsOriginAllowed with an explicit predicate, or WithOrigins(...) listing the trusted front ends.

django-cors-headers. CORS_ALLOW_ALL_ORIGINS = True emits a wildcard unconditionally, which quietly breaks every view that relies on session cookies once the frontend switches to credentials: 'include'. Move those origins into CORS_ALLOWED_ORIGINS instead, and keep CORS_ALLOW_CREDENTIALS scoped to the views that need it.

Nginx. add_header directives are not inherited into a nested location block that declares any add_header of its own, so a policy set at server level can vanish on exactly the path you care about. Set the reflected value through a map and repeat the add_header ... always directives in each block that needs them.

Common Mistakes

Issue Technical impact Mitigation
Wildcard on a cookie-authenticated endpoint Browser blocks the credentialed response; the API appears broken Reflect the validated origin with Allow-Credentials: true
Reflecting Origin without an allowlist Any site can read authenticated responses Match against a trusted set before echoing
Reflection without Vary: Origin CDN serves one origin’s header to another; intermittent failures Add Vary: Origin to every reflected response
Wildcard on a per-tenant public endpoint Any origin can read another tenant’s data Reflect a validated origin even without credentials

FAQ

Is Access-Control-Allow-Origin: * ever safe?

Yes, for genuinely public resources that carry no credentials and expose no per-user or per-tenant data: public fonts, open datasets, CDN assets, and unauthenticated read-only APIs. There the wildcard is simpler and cache-friendly because the response is identical for every origin. It becomes unsafe the moment the endpoint relies on cookies or tokens or returns caller-scoped data.

Why can’t I use a wildcard with credentials?

The WHATWG Fetch Standard forbids Access-Control-Allow-Origin: * when Access-Control-Allow-Credentials is true. The browser rejects the response before JavaScript can read it, preventing any origin from reading an authenticated response. With credentials you must echo the exact validated origin string.

Is reflecting the Origin header the same as a wildcard?

Only if you reflect it without validation. Echoing whatever Origin arrives with no allowlist is effectively a wildcard that also works with credentials — the worst case, since any site can then read authenticated responses. Reflection is safe only when the origin is first matched against a trusted allowlist and the response includes Vary: Origin.

What should the server send when the origin is not on the allowlist?

Nothing. Omit Access-Control-Allow-Origin entirely and leave the status code and body as they are; the browser will block the read and print its standard message. Do not fall back to a wildcard, because on a credential-free endpoint that hands the body to the origin you just rejected, and on a credentialed endpoint it produces a misleading wildcard-with-credentials error instead of a clean denial. Returning a 403 for an unlisted origin is also unnecessary and makes the API harder to debug, since the absence of the header is already the enforcement.

Does a wildcard endpoint need Vary: Origin?

No. A wildcard response does not depend on the request Origin, so there is nothing for a cache to partition on and Vary: Origin only fragments the cache for no benefit. The moment you switch that endpoint to reflection, Vary: Origin becomes mandatory on every response including the preflight, and you should check that your CDN actually honours it for Origin rather than silently ignoring everything except Accept-Encoding.