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
- Why This Matters
- The Standard: Explicit Origin Whitelisting
- Credentials and the Wildcard Rule
- CORS Headers Reference
- Preflight Requests
- Common Misconfigurations
- Official Tooling
- 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
| Environment | Allowed value for Access-Control-Allow-Origin |
|---|---|
| Production | One specific origin per response (dynamically matched from whitelist) |
| Staging | Specific 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 value | Valid? | Notes |
|---|---|---|
https://app.example.com | ✅ | Exact match |
https://app.example.com:443 | ✅ | Explicit port — treat as distinct from no-port |
http://app.example.com | ✅ if intentional | HTTP and HTTPS are different origins |
https://*.example.com | ❌ | Wildcards in origin values are not supported by the spec |
* | ❌ in production | Allows any origin — forbidden outside local dev |
null | ❌ | Can 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: *andAccess-Control-Allow-Credentials: trueis 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-Origin | Access-Control-Allow-Credentials | Browser behavior |
|---|---|---|
https://app.example.com | true | ✅ Credentials included, response accessible |
https://app.example.com | false (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)
| Header | Purpose | Required? |
|---|---|---|
Access-Control-Allow-Origin | Specifies which origin can read the response | Always |
Access-Control-Allow-Credentials | Allows cookies/auth headers cross-origin | Only when needed |
Access-Control-Allow-Methods | Lists allowed HTTP methods (for preflight) | On preflight response |
Access-Control-Allow-Headers | Lists allowed request headers (for preflight) | On preflight response |
Access-Control-Expose-Headers | Headers the browser is allowed to read from the response | When exposing custom headers |
Access-Control-Max-Age | How long the browser can cache a preflight result (seconds) | Optional, reduces OPTIONS traffic |
Vary: Origin | Tells caches that the response varies by Origin | Always when origin is dynamic |
Request headers (sent by the browser)
| Header | When present |
|---|---|
Origin | On all cross-origin requests |
Access-Control-Request-Method | On preflight (OPTIONS) requests only |
Access-Control-Request-Headers | On 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: Originautomatically 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'scrossOriginResourcePolicyalone — 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
| Task | Approach |
|---|---|
| Allow one origin | res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com') |
| Allow multiple origins | Whitelist Set → match req.headers.origin → echo matched value + Vary: Origin |
| Allow cookies cross-origin | Specific origin + Access-Control-Allow-Credentials: true |
| Reduce preflight traffic | Access-Control-Max-Age: 86400 |
| Expose a custom response header | Access-Control-Expose-Headers: X-Your-Header |
| Environment-aware config | Load allowed origins from env vars — never from code constants |
Questions or edge cases not covered here? Open a discussion in the #engineering-standards channel.