Skip to main content

CORS Standards

From the CTO — Required reading for all product and engineering teams. A misconfigured CORS policy is a silent security hole. It is invisible in most testing environments and only becomes obvious after an incident. Get it right the first time.


Table of Contents

  1. Why This Matters
  2. The Standard: Explicit Origin Whitelisting
  3. Credentials and the Wildcard Rule
  4. CORS Headers Reference
  5. Preflight Requests
  6. Common Misconfigurations
  7. Official Tooling
  8. Quick Reference

Why This Matters

CORS is a browser security mechanism that controls which origins can make requests to your API. When configured incorrectly, it either breaks legitimate clients or opens your API to cross-site request attacks.

What is at risk

Credential theft via CSRF. If your API allows any origin to make authenticated requests — by reflecting the caller's Origin header and setting Access-Control-Allow-Credentials: true — a malicious website can silently issue requests on behalf of a logged-in user. The browser attaches the user's session cookies automatically. The attacker's server receives the response.

Sensitive data exposure. APIs that return user data or internal state with Access-Control-Allow-Origin: * expose that data to any page the user visits. No authentication is needed for an attacker to scrape it.

False sense of security. CORS is enforced by browsers. A server with * as its allowed origin does not prevent direct API calls from scripts, curl, or backend clients. Restricting CORS is about protecting browser users, not locking down the API itself — but browser users are your most vulnerable surface.

Caching attacks. If a server dynamically sets Access-Control-Allow-Origin based on the request Origin but omits the Vary: Origin header, a CDN or proxy may cache the response from one origin and serve it to requests from a different origin. This breaks legitimate clients and can expose data unexpectedly.

Architectural warning: CORS is not an authentication mechanism. It is not a substitute for verifying tokens, validating sessions, or enforcing authorization on every request. Even with a perfect CORS configuration, every endpoint must still verify that the caller is authorized to perform the action.


The Standard: Explicit Origin Whitelisting

Every API must define an explicit, finite list of allowed origins. This list must be maintained in environment-specific configuration — not hardcoded, and not set to * in production.

The rule

EnvironmentAllowed value for Access-Control-Allow-Origin
ProductionOne specific origin per response (dynamically matched from whitelist)
StagingSpecific staging origins only — never *
Development* or localhost variants are acceptable

Allowed origin format

Origins are scheme + host + port. All three components must match exactly.

Origin valueValid?Notes
https://app.example.comExact match
https://app.example.com:443Explicit port — treat as distinct from no-port
http://app.example.com✅ if intentionalHTTP and HTTPS are different origins
https://*.example.comWildcards in origin values are not supported by the spec
*❌ in productionAllows any origin — forbidden outside local dev
nullCan be spoofed via sandboxed iframes — never allowlist null

Dynamic matching — how to do it correctly

Because the Access-Control-Allow-Origin response header can only contain a single origin value (not a list), servers that allow multiple origins must check the incoming Origin header against the whitelist and echo the matched value.

const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
]);

function resolveOrigin(requestOrigin: string | undefined): string | null {
if (!requestOrigin) return null;
return ALLOWED_ORIGINS.has(requestOrigin) ? requestOrigin : null;
}

Critical: the comparison must be an exact match against a Set or fixed array. Do not use string .includes(), .startsWith(), or regex unless you have reviewed the pattern for bypass vulnerabilities (see Common Misconfigurations).

When the matched origin is set dynamically, always include the Vary: Origin header so caches treat requests from different origins as distinct responses.


Credentials and the Wildcard Rule

Access-Control-Allow-Credentials: true tells the browser to expose the response to JavaScript when the request was made with credentials (cookies, HTTP authentication, or client-side certificates).

The browser enforcement rule

A response with Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true is rejected by every compliant browser. This combination is forbidden by the CORS specification (Fetch Standard, §3.2.5).

The combination is not just a bad idea — it does not work. The browser will block the response before your JavaScript can read it.

Access-Control-Allow-OriginAccess-Control-Allow-CredentialsBrowser behavior
https://app.example.comtrue✅ Credentials included, response accessible
https://app.example.comfalse (or absent)✅ Cookies not sent, response accessible
*false (or absent)✅ No credentials, response accessible
*true❌ Browser rejects the response

When to set Access-Control-Allow-Credentials: true

Only when the front-end client needs to send cookies or HTTP authentication headers cross-origin and the origin is explicitly trusted. This applies to:

  • Browser-based apps on a subdomain that call the API on the primary domain.
  • OAuth redirect flows where cookies must be forwarded.

Do not set it by default. If you are using token-based authentication via the Authorization header, credentials mode is not needed.


CORS Headers Reference

Response headers (set by your server)

HeaderPurposeRequired?
Access-Control-Allow-OriginSpecifies which origin can read the responseAlways
Access-Control-Allow-CredentialsAllows cookies/auth headers cross-originOnly when needed
Access-Control-Allow-MethodsLists allowed HTTP methods (for preflight)On preflight response
Access-Control-Allow-HeadersLists allowed request headers (for preflight)On preflight response
Access-Control-Expose-HeadersHeaders the browser is allowed to read from the responseWhen exposing custom headers
Access-Control-Max-AgeHow long the browser can cache a preflight result (seconds)Optional, reduces OPTIONS traffic
Vary: OriginTells caches that the response varies by OriginAlways when origin is dynamic

Request headers (sent by the browser)

HeaderWhen present
OriginOn all cross-origin requests
Access-Control-Request-MethodOn preflight (OPTIONS) requests only
Access-Control-Request-HeadersOn preflight when the request uses non-simple headers

Preflight Requests

Browsers send a preflight OPTIONS request before any cross-origin request that is not a "simple request." A simple request uses GET, HEAD, or POST with only safe headers and a simple content type.

Most API calls — those using Authorization, Content-Type: application/json, or non-standard headers — trigger a preflight.

What a preflight exchange looks like

Browser sends:

OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization, Content-Type

Server must respond:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Vary: Origin

If the preflight response is missing or incorrect, the browser blocks the actual request without sending it.

Caching preflight results

Set Access-Control-Max-Age to reduce the number of preflight round trips. A value of 86400 (24 hours) is reasonable. Browsers cap this at their own internal maximum (Chrome: 2 hours, Firefox: 24 hours).


Common Misconfigurations

1. Reflecting the Origin header without validation

// ❌ Dangerous — allows any origin by echoing it back
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*');
next();
});

This is equivalent to * but bypasses naive security checks. Every origin the server receives is reflected back as trusted.


2. Regex that matches more than intended

// ❌ This matches 'evil-mycompany.com' and 'notmycompany.com'
const isAllowed = /mycompany\.com/.test(requestOrigin);

// ✅ Anchor the regex, or better — use a Set for exact matching
const isAllowed = /^https:\/\/mycompany\.com$/.test(requestOrigin);
// Even better:
const isAllowed = ALLOWED_ORIGINS.has(requestOrigin);

3. Allowing the null origin

null appears as the Origin header in requests from sandboxed iframes, file:// pages, and some redirect chains. Allowing null is exploitable:

// ❌ Never do this
if (origin === 'null' || origin === null) {
res.setHeader('Access-Control-Allow-Origin', 'null');
}

4. Missing Vary: Origin

When the server dynamically sets Access-Control-Allow-Origin based on the request, a caching layer may serve a response intended for origin A to a request from origin B.

// ❌ Dynamic origin without Vary header
res.setHeader('Access-Control-Allow-Origin', matchedOrigin);

// ✅ Always pair with Vary
res.setHeader('Access-Control-Allow-Origin', matchedOrigin);
res.setHeader('Vary', 'Origin');

5. Overly broad Access-Control-Allow-Headers

// ❌ Reflects whatever headers the client requests
res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers'] ?? '');

// ✅ Enumerate exactly the headers your API accepts
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Request-ID');

Official Tooling

For Node.js and Express, use the cors package.

npm install cors
npm install --save-dev @types/cors

Why this package:

  • Maintained, well-tested, handles preflight automatically.
  • Supports dynamic origin functions with the correct response format.
  • Handles Vary: Origin automatically when using a dynamic origin function.

Do not use:

  • Manually setting headers via middleware chains without a dedicated CORS package — error-prone and hard to audit.
  • helmet's crossOriginResourcePolicy alone — that is a different header (Cross-Origin-Resource-Policy) for a different purpose.

Quick Reference

✅ Allowed origins: explicit Set or array — never a regex without anchors
✅ Dynamic origin matching: exact Set.has() + Vary: Origin header
✅ Credentials: only with specific trusted origin — never with *
✅ Preflight: respond to OPTIONS with correct Allow-Methods and Allow-Headers
✅ Access-Control-Max-Age: 86400 (reduces round trips)

❌ Never: Access-Control-Allow-Origin: * in production
❌ Never: reflect req.headers.origin without validating against whitelist
❌ Never: allow the 'null' origin
❌ Never: set Credentials: true without a specific, trusted origin
❌ Never: dynamic origin response without Vary: Origin
TaskApproach
Allow one originres.setHeader('Access-Control-Allow-Origin', 'https://app.example.com')
Allow multiple originsWhitelist Set → match req.headers.origin → echo matched value + Vary: Origin
Allow cookies cross-originSpecific origin + Access-Control-Allow-Credentials: true
Reduce preflight trafficAccess-Control-Max-Age: 86400
Expose a custom response headerAccess-Control-Expose-Headers: X-Your-Header
Environment-aware configLoad allowed origins from env vars — never from code constants

Questions or edge cases not covered here? Open a discussion in the #engineering-standards channel.