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:
Prerequisite State
- You know whether the endpoint sends credentials (
fetch(..., { credentials: 'include' }), cookies, orAuthorization). - You know whether the response body is public or scoped to the caller.
- You have a defined list of trusted front-end origins if reflection is required.
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:
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.
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.