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:

Origin Serialization Pipeline A flow diagram showing how a browser converts a raw URL into a normalized origin tuple. Steps: extract scheme, lowercase scheme and host, apply Punycode to Unicode hostnames, drop path/query/fragment, omit default ports, then concatenate as scheme://host or scheme://host:port. Step 1 Extract scheme + host Step 2 Lowercase scheme & host Step 3 Punycode Unicode hosts Step 4 Drop path, query, fragment Step 5 Omit default port (80/443) Step 6 Keep explicit non-default port Step 7 Concatenate scheme://host[:port] Serialized Origin https:// api.example.com RFC 6454 / WHATWG URL Standard normalization pipeline (browser-side)

Key normalization invariants:

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:

  1. Wildcard + credentials is always blocked. When Access-Control-Allow-Origin: * appears alongside Access-Control-Allow-Credentials: true, the browser discards the response regardless of origin. This is a hard spec prohibition, not a browser quirk.
  2. 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.