Origin Matching Rules & Validation
Without strict origin-tuple matching, a browser has no reliable way to distinguish a legitimate cross-origin request from a forged one. When validation is absent or incorrectly implemented, authenticated API endpoints become reachable from any domain the attacker controls — no XSS required, just a crafted fetch() call.
This page is part of Core CORS Mechanics & Same-Origin Policy Fundamentals, which covers how the browser enforces the same-origin policy end to end. Here the focus is narrower: the exact algorithm browsers use to parse and serialize an origin, the rules servers must follow when comparing it, and the secure allowlist patterns that tie both sides together.
How the Origin Tuple Is Built and Serialized
The authoritative definition lives in RFC 6454 and the WHATWG URL Standard. A browser constructs an origin as a three-part tuple — (scheme, host, port) — and serializes it to a single ASCII string before attaching it to the Origin request header.
The diagram below shows the normalization pipeline every browser runs before serialization:
Key normalization invariants:
- Scheme and host are lowercased.
HTTPS://App.Example.combecomeshttps://app.example.com. - Default ports are omitted. Port 80 for
httpand port 443 forhttpsare dropped from the serialized string. An explicit:443suffix causes a mismatch against an allowlist entry without it. - Unicode hostnames are Punycode-encoded.
münchen.debecomesxn--mnchen-3ya.debefore comparison. - Path, query, and fragment are discarded. The origin tuple never includes anything past the authority component.
- Trailing slashes are dropped.
https://example.com/serializes tohttps://example.com.
Normalization examples
| Raw URL | Serialized Origin | Notes |
|---|---|---|
https://App.Example.com/path?q=1 |
https://app.example.com |
Uppercase host lowercased; path stripped |
http://app.example.com:80/api |
http://app.example.com |
Default HTTP port omitted |
https://app.example.com:443 |
https://app.example.com |
Default HTTPS port omitted |
https://app.example.com:8443 |
https://app.example.com:8443 |
Non-default port retained |
https://münchen.de/ |
https://xn--mnchen-3ya.de |
Punycode applied; slash stripped |
Understanding how how browsers evaluate the same-origin policy handles opaque origins and sandboxed contexts prevents false-positive validation failures when one of these invariants applies unexpectedly.
Header and Parameter Reference
The Access-Control-Allow-Origin (ACAO) header is the server’s sole mechanism for declaring which origin is permitted to read a response. All comparison happens after the browser’s normalization pipeline runs.
| Header / Value | Type | Allowed Values | Default | Browser Behavior |
|---|---|---|---|---|
Access-Control-Allow-Origin |
Response header | Exact serialized origin string or * |
None (blocked) | Byte-for-byte string match against serialized Origin; no regex, no lists |
Origin |
Request header | Serialized tuple or null |
Omitted on same-origin | Set by browser; cannot be overridden by JavaScript |
Vary: Origin |
Response header | Origin |
Not set | Required when ACAO is dynamic; tells caches to key on Origin value |
Access-Control-Allow-Credentials |
Response header | true |
false |
Incompatible with wildcard ACAO; blocks response if both are present |
The browser enforces two constraints that cannot be worked around in the header itself:
- Wildcard + credentials is always blocked. When
Access-Control-Allow-Origin: *appears alongsideAccess-Control-Allow-Credentials: true, the browser discards the response regardless of origin. This is a hard spec prohibition, not a browser quirk. - Comma-separated lists are invalid. The ACAO header cannot contain
https://a.com, https://b.com. The browser treats the entire string — commas included — as a literal origin to match, which will always fail.
Step-by-Step Implementation
1. Build the allowlist
Store normalized origin strings — no trailing slashes, no default ports — in a data structure that supports O(1) lookup. Configuration files or environment variables are preferable to hardcoded values so staging origins never appear in production.
# Nginx: map-based allowlist with dynamic ACAO reflection
map $http_origin $cors_origin {
default "";
"https://app.secure-platform.io" "https://app.secure-platform.io";
"https://admin.secure-platform.io" "https://admin.secure-platform.io";
}
server {
location /api/ {
if ($cors_origin != "") {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary Origin always;
}
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type";
add_header Access-Control-Max-Age 86400;
add_header Vary Origin always;
return 204;
}
proxy_pass http://upstream;
}
}
2. Parse and validate the Origin header
On every request, read req.headers.origin (not Host) and perform a strict set lookup before writing any CORS header:
// Express/Node: strict set-based validation with dynamic reflection
const ALLOWED_ORIGINS = new Set([
'https://app.secure-platform.io',
'https://admin.secure-platform.io'
]);
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');
}
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', '86400');
return res.sendStatus(204);
}
next();
}
3. Normalize before comparing (when using regex patterns)
If your allowlist must cover a subdomain range, normalize the incoming value before testing it. Explicitly strip any trailing :443 or :80 that a misbehaving client might send, then test against a pattern anchored at both ends:
// Safe subdomain-range validation with explicit port normalization
const ORIGIN_PATTERN = /^https:\/\/([a-z0-9-]+\.)?secure-platform\.io$/;
function isValidOrigin(origin) {
if (!origin || typeof origin !== 'string') return false;
// Normalize explicit default ports to implicit form before matching
const normalized = origin.replace(/:443$/, '').replace(/:80$/, '');
return ORIGIN_PATTERN.test(normalized);
}
Anchoring with ^ and $ prevents bypass attempts such as https://evil.secure-platform.io.attacker.com.
4. Echo the matched origin, not a recomputed value
Write the value from the Set (or the matched allowlist entry) directly to the response header — not a recomputed version. This avoids subtle bugs where normalization differences between your code and the browser produce a mismatch. The reflected value must be byte-for-byte identical to what the browser sent.
5. Always set Vary: Origin
Any time ACAO is set dynamically, append Origin to the Vary header. Omitting it means a CDN or reverse proxy may cache a response with one origin’s ACAO value and serve it to requests from a different origin, producing intermittent failures that are hard to reproduce. See handling the Vary: Origin header correctly for caching-layer specifics.
Edge Cases and Security Boundaries
Subdomain normalization
Subdomains are fully independent origins. https://api.example.com and https://app.example.com are distinct tuples even though they share a registrable domain. Your allowlist must enumerate each subdomain explicitly, or use a validated subdomain pattern as shown in step 3 above.
The null origin
Browsers send Origin: null from sandboxed iframes (<iframe sandbox> without allow-same-origin), data: URLs, and some file:// contexts. Reflecting null in the ACAO header is almost always wrong: it grants access to any sandboxed context on any page, not just your own. Reject null explicitly:
if (origin && origin !== 'null' && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
If you genuinely need a sandboxed iframe to reach your API, add allow-same-origin to the sandbox attribute and serve the iframe from a controlled subdomain.
Opaque responses from no-cors fetches
A fetch(url, { mode: 'no-cors' }) call returns an opaque response: status 0, empty body, no readable headers. The browser still enforces origin isolation — JavaScript cannot inspect the response — so opaque responses cannot be used to exfiltrate data. They are only appropriate for fire-and-forget requests such as telemetry pings.
Redirects and intermediate origins
When a credentialed request is redirected, the browser strips the Authorization header and may rewrite the Origin to null depending on the redirect type. Avoid issuing redirects for authenticated cross-origin endpoints; return the final URL directly.
Sec-Fetch-Site as a secondary signal
Modern browsers send Sec-Fetch-Site: cross-site (or same-origin, same-site, none) on all requests. This header is set by the browser and cannot be spoofed by JavaScript. Use it as a supplementary check, not a replacement for ACAO validation, because older browsers and non-browser HTTP clients do not send it.
Proxy and CDN Interaction
Origin validation breaks silently when an intermediary rewrites or drops the Origin header. Three patterns cause this:
Proxy stripping. Nginx and HAProxy may omit non-standard request headers by default. Add explicit pass-through:
proxy_set_header Origin $http_origin;
For AWS Application Load Balancer, ensure the listener rule does not include an Origin header override. For Cloudflare, Origin is forwarded unchanged to the origin server; no additional configuration is needed.
CDN cache keying. A CDN that does not key its cache on Origin will serve one response to all origins. The cached ACAO value will match only the first requester. Setting Vary: Origin on every dynamic ACAO response instructs the CDN to maintain separate cache entries per origin.
TLS termination at the load balancer. When TLS terminates before your application server, the Origin header the server sees still contains https:// — the scheme reflects what the client used, not what protocol the internal hop used. Do not rewrite the scheme inside your validation logic based on X-Forwarded-Proto.
The preflight optimization and caching strategies section covers how Access-Control-Max-Age and CDN configuration interact with the preflight cache — reducing redundant OPTIONS requests without loosening origin validation.
DevTools and curl Verification Checklist
Use this checklist after deploying any change to your CORS validation logic:
For OPTIONS endpoint–specific verification, see designing lightweight OPTIONS endpoints.
Common Mistakes
| Issue | Technical Impact | Mitigation |
|---|---|---|
Using * with Access-Control-Allow-Credentials: true |
Browser blocks the response entirely; authenticated flows silently fail | Switch to dynamic origin reflection with a strict allowlist |
Omitting Vary: Origin when echoing ACAO dynamically |
CDN serves a single cached ACAO value to all origins; intermittent failures across different client domains | Always set Vary: Origin on responses where ACAO is set dynamically |
Validating Host instead of Origin for CORS decisions |
Host header injection or X-Forwarded-Host manipulation bypasses validation |
Read req.headers.origin exclusively; never use Host for CORS |
Allowing Origin: null in production |
Any sandboxed iframe — including attacker-controlled ones — passes validation | Reject null explicitly at the top of your validation function |
| Storing allowlist entries with explicit default ports | https://app.io:443 never matches https://app.io sent by the browser |
Store all allowlist entries in their implicit-port form |
Anchoring regex only at the start (^) |
https://app.io.evil.com matches ^https://app\.io |
Always use both ^ and $ anchors in origin pattern matches |
Reflecting the raw Origin header without validation |
Any origin gains access; equivalent to * but harder to detect in audits |
Always validate against the allowlist before reflecting |
FAQ
Why does my exact origin match fail despite correct casing?
Browsers perform byte-for-byte matching on the serialized origin. Verify scheme, host, and port are identical in both the request and your allowlist entry. The most common cause is an explicit default port in the allowlist — https://api.example.com:443 — that the browser sends as https://api.example.com. Use curl -v to inspect the exact Origin header value the browser transmits.
Can I use regex in the Access-Control-Allow-Origin header?
No. The ACAO header only accepts an exact origin string or the single wildcard *. Regex validation must occur server-side before you dynamically set the header to the matched string. The browser never interprets ACAO as a pattern.
How do I safely validate origins in a microservices architecture?
Centralize origin allowlists in a shared configuration service — a distributed key-value store or a secrets manager — and enforce strict set-based matching at the API gateway. Downstream services should receive a validated internal header (e.g. X-Validated-Origin) set by the gateway, never re-read the raw Origin themselves.
Does the Origin header always match the requesting page URL?
Not always. Redirects, sandboxed iframes, and privacy-hardening browser extensions can strip or rewrite the Origin header. For server-to-server requests and non-browser clients, Origin may be absent entirely. Implement Sec-Fetch-Site as a secondary signal for same-site detection, and document clearly which endpoints expect browser-originated requests.
What happens when the Origin header contains an explicit default port?
The browser omits default ports from the serialized origin it sends, so you will rarely see :443 or :80 in a real Origin header. However, some HTTP clients and test tools send them explicitly. Normalize incoming values by stripping explicit default ports before your allowlist lookup to avoid a mismatch caused by a testing tool rather than a misconfiguration.
Related
- Core CORS Mechanics & Same-Origin Policy Fundamentals — parent section covering the full same-origin policy enforcement model
- How Browsers Evaluate the Same-Origin Policy — opaque origins, sandboxed contexts, and the browser’s internal enforcement steps
- Credential Sharing & Security Boundaries — cookie and auth-token isolation rules that interact with origin validation
- Dynamic Origin Validation Patterns — framework-specific allowlist implementations for Nginx, Express, and beyond
- Handling the Vary: Origin Header Correctly — CDN cache-keying requirements when ACAO is set dynamically
- Simple vs Preflight Requests — which request types trigger the origin matching check at preflight versus response time