Dynamic Origin Validation Patterns
Without server-side origin validation, a CORS implementation must choose between a static list that breaks at the first new tenant or a wildcard that surrenders every credential isolation guarantee. Dynamic validation is the runtime path between those extremes: the server evaluates the incoming Origin header on each request, confirms it belongs to an allowlist, and conditionally reflects it — never echoing blindly, never falling back to *. This page is a sub-topic of Server-Side CORS Configuration & Header Management, which covers the full header lifecycle from request parsing to response delivery.
The WHATWG Fetch Standard (section 3.2, “HTTP-network fetch”) requires exact string equality between the request Origin and the Access-Control-Allow-Origin value in the response when credentials are involved. That rule makes origin reflection safe only after verified membership in a controlled set.
Origin Header Reference
Understanding what the browser sends — and in what form — is the prerequisite for writing validation logic that cannot be bypassed.
| Field | Type | Possible values | Browser behaviour |
|---|---|---|---|
Origin request header |
String or absent | Scheme + host + optional port, e.g. https://app.example.com or https://api.example.com:8443; the literal string null for sandboxed contexts |
Sent on every cross-origin request and on same-origin POST with non-simple headers; omitted on same-origin navigations |
Access-Control-Allow-Origin response header |
String | Reflected origin (exact), *, or absent |
Must match the request Origin exactly when credentials are in play; * blocks credentials |
Vary response header |
String | Must include Origin whenever the Access-Control-Allow-Origin value differs per caller |
Signals caches to partition by origin; omitting it poisons shared caches |
Access-Control-Allow-Credentials response header |
Boolean string | true or absent |
Works only with a reflected, non-wildcard origin |
Access-Control-Max-Age response header |
Integer (seconds) | Browser upper bounds: Chrome 7200 s, Firefox 86400 s | Controls how long the preflight result is cached per origin |
How the Validation Decision Flows
The diagram below shows the server-side decision path from receiving an Origin header to emitting — or withholding — Access-Control-Allow-Origin.
Both the match and no-match branches append Vary: Origin. Omitting it on the no-match branch still allows a cached “no ACAO header” response to be served to a legitimate origin that hits the same CDN node next.
Step-by-Step Implementation
1. Parse and normalise the incoming Origin
The WHATWG Fetch Standard defines the origin as a tuple of scheme, host, and optional port. Before any comparison, lowercase the host component and strip any trailing slash. Do not strip the port — https://app.example.com:443 and https://app.example.com are distinct strings even though port 443 is the HTTPS default.
function normaliseOrigin(raw) {
if (!raw || raw === 'null') return raw; // preserve the literal "null" for separate handling
try {
const url = new URL(raw);
return `${url.protocol}//${url.hostname.toLowerCase()}${url.port ? ':' + url.port : ''}`;
} catch {
return null; // unparseable — treat as invalid
}
}
2. Validate against an exact-match allowlist (Express.js)
For Access-Control-* header directives that must vary per caller, exact matching is the safest baseline. Load the allowlist from an environment variable or a cache-backed store:
const ALLOWED_ORIGINS = new Set(
(process.env.ALLOWED_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean)
);
app.use((req, res, next) => {
const origin = normaliseOrigin(req.headers.origin);
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
} else {
// Still vary so caches do not serve this no-ACAO response to a valid origin
res.setHeader('Vary', 'Origin');
}
next();
});
3. Extend with anchored regex for multi-tenant routing
When tenant subdomains are provisioned dynamically, exact matching becomes impractical. Regex is acceptable only with full anchoring and an explicit scheme prefix. For the full implementation details, see Express.js dynamic origin allowlist implementation.
// GOOD: anchored, scheme-explicit, character class limited
const TENANT_PATTERN = /^https:\/\/[a-z0-9-]{1,63}\.tenant\.example\.com$/;
// BAD: unanchored — matches "evil-tenant.example.com.attacker.io"
// const TENANT_PATTERN = /tenant\.example\.com/;
app.use((req, res, next) => {
const origin = normaliseOrigin(req.headers.origin);
const isValid = origin && (
ALLOWED_ORIGINS.has(origin) ||
TENANT_PATTERN.test(origin)
);
res.setHeader('Vary', 'Origin');
if (isValid) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
next();
});
4. Handle preflight separately
Validation logic must also gate OPTIONS preflight requests before the browser sends the actual request:
app.options('*', (req, res) => {
const origin = normaliseOrigin(req.headers.origin);
const isValid = origin && (ALLOWED_ORIGINS.has(origin) || TENANT_PATTERN.test(origin));
res.setHeader('Vary', 'Origin');
if (isValid) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.setHeader('Access-Control-Max-Age', '3600');
}
res.status(isValid ? 204 : 403).end();
});
5. Infrastructure-level validation with Nginx
For configuring CORS in Nginx for multiple origins, use a map block to resolve the incoming origin to a variable that is either the origin itself or an empty string. Emitting the header with an empty value is distinct from omitting it entirely, so use a conditional block:
map $http_origin $cors_origin_valid {
default "";
"https://app.example.com" "$http_origin";
"https://admin.example.com" "$http_origin";
"~^https://[a-z0-9-]+\.tenant\.example\.com$" "$http_origin";
}
server {
location /api/ {
# Only emit the header when the map resolved to a non-empty value
if ($cors_origin_valid) {
add_header Access-Control-Allow-Origin $cors_origin_valid always;
}
add_header Vary Origin always;
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Methods "GET,POST,PUT,DELETE,PATCH" always;
add_header Access-Control-Allow-Headers "Content-Type,Authorization" always;
add_header Access-Control-Max-Age 3600 always;
return 204;
}
proxy_pass http://backend;
}
}
The always flag forces Nginx to include the header even on error responses. Without it, 4xx and 5xx responses omit CORS headers and the browser blocks the error body from JavaScript — hiding the real failure reason.
6. Database-backed allowlist with TTL caching
For runtime tenant onboarding without service restarts, back the allowlist with a database query and a short-lived in-process cache:
const cache = new Map();
const CACHE_TTL_MS = 5 * 60 * 1000;
async function isOriginAllowed(origin, db) {
const cached = cache.get(origin);
if (cached !== undefined) return cached;
const { rowCount } = await db.query(
'SELECT 1 FROM cors_allowlist WHERE origin = $1 AND active = TRUE',
[origin]
);
const valid = rowCount > 0;
cache.set(origin, valid);
setTimeout(() => cache.delete(origin), CACHE_TTL_MS);
return valid;
}
Add a circuit breaker around the database call. If the store is unreachable, fail closed (reject the origin) rather than falling back to a permissive default. Log the validation result and origin value on every request for audit purposes.
Edge Cases and Security Boundaries
The null origin
Sandboxed iframes with no allow-same-origin attribute, data: URLs, and certain redirect chains all send Origin: null. This is a literal string, not an absent header. Adding "null" to an allowlist is dangerous because any cross-origin iframe — including attacker-controlled ones — produces the same value. Accept null only for specific controlled embed use cases where the embedding page is under your control, and document the decision explicitly.
Subdomain normalisation edge cases
https://app.example.com and https://APP.EXAMPLE.COM are the same origin per the URL Standard (hosts are case-insensitive), but string comparison treats them as different. Always lowercase before comparing. Port omission is trickier: the browser includes the port when it is non-default (https://app.example.com:8443) and omits it when it is default (https://app.example.com for port 443). Validate against what the browser actually sends, not what you expect it to send.
Opaque responses and no-cors mode
When JavaScript initiates a fetch with mode: 'no-cors', the browser makes the request but returns an opaque response (type "opaque", status 0, body unreadable). No Origin validation is relevant because the browser never reads the response headers. The risk here is server-side state mutation from the unvalidated request — enforce authentication and CSRF tokens independently of CORS.
Wildcard risks
Never fall back to Access-Control-Allow-Origin: * when validation fails. Wildcard reflection bypasses credential isolation and conflicts with Access-Control-Allow-Credentials: true — the browser will reject the response entirely when both appear. For a full treatment of this trade-off, see Wildcard Risks & Mitigation.
Proxy and CDN Interaction
Dynamic CORS responses create cache partitioning requirements. When Access-Control-Allow-Origin varies by caller, every CDN and reverse proxy in the path must be configured to key its cache on Origin.
Vary: Origin is mandatory. Without it, a CDN caches the first response it sees for a given URL and serves that cached Access-Control-Allow-Origin header to all subsequent callers regardless of their origin. A caller from https://attacker.example.com may receive a response that includes a valid origin from a legitimate tenant.
Strip Origin on non-CORS routes. For API endpoints that do not need CORS, have the edge layer strip the Origin request header before it reaches your origin server. This prevents accidental reflection and eliminates the Vary overhead on routes that never emit CORS headers.
Forward validated origins upstream. When edge workers handle CORS validation before proxying to an upstream API, forward the validated origin via a custom header such as X-Validated-Origin. The upstream can trust this header for audit logging without re-running validation on every request.
CDN edge workers. When deploying origin validation in a Cloudflare Worker or similar edge function, keep the allowlist in a KV store with a short TTL rather than bundling it at deploy time. This allows tenant onboarding without redeployment, and the TTL ensures stale entries expire within minutes rather than hours.
DevTools and curl Verification Checklist
Use this checklist after deploying or modifying validation logic:
curl -si -X OPTIONS \ -H "Origin: https://app.example.com" \ -H "Access-Control-Request-Method: POST" \ https://api.example.com/resource | grep -i "access-control\|vary"curl -si -X OPTIONS \ -H "Origin: https://attacker.example.com" \ -H "Access-Control-Request-Method: POST" \ https://api.example.com/resource | grep -i "access-control"curl -si -H "Origin: https://app.example.com" \ -H "Cookie: session=abc123" \ https://api.example.com/resource | grep -i "access-control\|vary"
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
Reflecting Origin without prior validation |
Any caller can set Origin to any value; unvalidated reflection grants cross-origin access to every caller including attackers |
Validate against an allowlist before calling setHeader |
Omitting Vary: Origin on dynamic responses |
CDN or browser cache serves a response for origin A to origin B; CORS header mismatch causes silent failures or false grants | Append Vary: Origin unconditionally on every route that evaluates the Origin header |
| Unanchored regex patterns | tenant\.example\.com matches evil-tenant.example.com.attacker.io; tenant isolation bypass |
Always anchor with ^ and $; test every pattern against spoofed strings |
| Treating HTTP and HTTPS origins as equivalent | Downgrade attack: a response cached for https://app.example.com can serve http://app.example.com, which is insecure |
Include the scheme in the exact-match or regex; never normalise away the protocol |
Falling back to * on allowlist miss |
Wildcard disables credential isolation; any cross-origin script can read the response | Omit Access-Control-Allow-Origin entirely on non-matching origins; return 403 for explicit preflight rejections |
Allowing null as a wildcard |
Any sandboxed iframe or redirect chain gains cross-origin access | Restrict null acceptance to specific, documented embed scenarios; never add it to the general allowlist |
FAQ
How does dynamic origin validation affect preflight caching?
Dynamic validation requires Vary: Origin on every response so that CDNs and browsers assign separate cache entries per origin. Omitting it causes a cached preflight response for one origin to be served to a different origin, producing stale or mismatched Access-Control-Allow-Origin headers. The browser’s preflight cache duration is keyed by both the URL and the origin when Vary: Origin is present.
Can regex safely replace exact-match origin validation?
Only with strict anchoring (^ and $) and explicit protocol enforcement. A loose pattern like example\.com matches evil-example.com. Always anchor and prefix with ^https:// to rule out HTTP downgrade paths and prefix-spoofing attacks. Test every regex against a comprehensive set of adversarial inputs before deploying.
Why do credentials silently drop after dynamic origin validation succeeds?
Access-Control-Allow-Credentials: true only works when the reflected origin is the exact string the browser sent. If validation passes but the header value differs by even one character — for example because port normalisation introduced a mismatch — the browser suppresses the credentials. Missing Vary that causes a cached null or wildcard response to be served produces the same symptom. Trace the exact bytes of both the request Origin and the response Access-Control-Allow-Origin header before concluding the application logic is correct.
How should sandboxed iframes be handled?
Sandboxed iframes with no allow-same-origin attribute emit Origin: null. Never add null to an allowlist unconditionally — any cross-origin iframe, redirect chain, or data: URL also produces null, so allowing it grants broad implicit access. Restrict null-origin acceptance to specific, controlled embed scenarios and document the rationale.
What is the correct Nginx approach for dynamic origin matching?
Use a map block to resolve $http_origin to a variable that is either the origin itself or an empty string, then emit Access-Control-Allow-Origin with that variable inside an if ($cors_origin_valid) block. This avoids the always pitfall where Nginx emits a blank Access-Control-Allow-Origin: header for unmatched origins, which some parsers treat as an empty string (a distinct value from an absent header).
Related
- Server-Side CORS Configuration & Header Management — parent: full header lifecycle and server enforcement model
- Access-Control-* Header Directives — reference for every response header this validation logic must emit
- Wildcard Risks & Mitigation — why
*fails as a fallback and what to do instead - Express.js Dynamic Origin Allowlist Implementation — full Node.js implementation with middleware, tests, and credential handling
- Configuring CORS in Nginx for Multiple Origins — Nginx map directives, regex anchoring, and
alwaysflag semantics