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.

Dynamic origin validation decision flow A flowchart showing how a server validates an incoming Origin header. Starting from receiving the request, the server checks whether an Origin header is present, then normalises and checks it against the allowlist, reflects it on match, or omits the header on no-match. Vary: Origin is appended in both the match and no-match branches to prevent cache poisoning. Incoming request Origin header present? Non-CORS request Normalise origin Allowlist match? Set ACAO = origin Set Vary: Origin Omit ACAO header Set Vary: Origin Respond to client No Yes Yes No

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:

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).