#Purpose
Rules for configuring CORS deliberately.
First, the correction that prevents most CORS mistakes: CORS is a relaxation of
the same-origin policy, not a security control. It decides whether a browser
lets page JavaScript read a cross-origin response. It does not stop the request
being sent, does not protect non-browser clients, and does not prevent CSRF —
see Security/csrf.
Loosening CORS can only ever weaken your position. Start closed.
#Explicit origins
jsconst ALLOWED = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
app.use((req, res, next) => {
const origin = req.get("Origin");
if (origin && ALLOWED.has(origin)) {
res.set("Access-Control-Allow-Origin", origin);
res.set("Vary", "Origin"); // required — see caching below
res.set("Access-Control-Allow-Credentials", "true");
}
next();
});
- Compare against an exact set. The header takes one origin, so with several
allowed you must echo the matched one — and
Vary: Originis then mandatory. - Never reflect an arbitrary
Origin.res.set("Access-Control-Allow-Origin", req.get("Origin"))with credentials enabled means every site can read every authenticated response. This is the single most damaging CORS misconfiguration. - Never match with
startsWith,endsWithor a loose regex.https://app.example.com.evil.tldpasses a prefix check;https://evil-app.example.compasses a naive suffix check. Compare full origins. - Never allow
null. It is sent by sandboxed iframes andfile://documents and is attacker-reachable.
#Credentials
Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true are
rejected together by browsers. Code that "fixes" this by reflecting the origin has
deliberately built the vulnerability the rule exists to prevent.
If the API uses bearer tokens rather than cookies, you may not need credentials
at all — and then * for genuinely public, unauthenticated endpoints is fine.
#Preflight
A preflight OPTIONS request is sent when the request is not "simple" — a method
beyond GET/HEAD/POST, a Content-Type other than the three form types, or
custom headers.
makefileAccess-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
- List only the methods and headers you actually accept.
Allow-Headers: *is ignored when credentials are used, and permissive otherwise. Max-Agecaches the preflight. Keep it modest so a policy change takes effect; browsers cap it regardless.- The preflight response must not require authentication — the browser sends it without credentials.
js// Answer the preflight explicitly. It must not require authentication —
// the browser sends OPTIONS without credentials.
app.options("*", (req, res) => {
const origin = req.get("Origin");
if (!origin || !ALLOWED.has(origin)) return res.status(403).end();
res.set({
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "600",
Vary: "Origin, Access-Control-Request-Headers",
});
res.status(204).end();
});
Requiring application/json is useful: it forces a preflight, which a simple
cross-site form cannot perform. That is a genuine CSRF benefit, though it comes
from the content type rather than from CORS itself.
#Caching and proxies
Vary: Origin is not optional when the allowed origin varies. Without it a CDN or
proxy may serve a response containing
Access-Control-Allow-Origin: https://app.example.com to a request from another
origin — cross-origin data disclosure through a cache.
The same applies to Vary: Access-Control-Request-Headers for preflight
responses that differ by requested headers.
#What CORS does not do
- It does not protect non-browser clients.
curl, a server, or a mobile app ignores CORS entirely. Authorisation must be enforced server-side regardless. - It does not prevent the request. A cross-site
POSTstill executes and still changes state; the attacker merely cannot read the reply. - It is not authentication. An origin is not an identity. Anyone can send an
Originheader of their choosing outside a browser.
#Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Reflecting any Origin with credentials | Every site reads authenticated responses | Exact allow-list |
origin.endsWith("example.com") | evil-example.com passes | Full-origin equality |
Allowing Origin: null | Reachable from sandboxed iframes | Never allow null |
Omitting Vary: Origin | Caches serve one origin's response to another | Always set it |
Allow-Origin: * on an authenticated API | Public reads of private data | Explicit origins |
| Treating CORS as CSRF protection | CORS gates reading, not sending | Tokens and SameSite |
| Wide CORS to "fix" a local dev error | Ships to production | Environment-specific config |
Long Max-Age | Policy changes take days to apply | Keep it short |
#Checklist
- Verify: Allowed origins are an exact set, compared by full-origin equality
- Verify: No reflection of arbitrary
Originvalues - Verify:
Origin: nullis never allowed - Verify:
Vary: Originis set whenever the allowed origin varies - Verify:
Allow-Credentialsis enabled only where cookies are genuinely required - Verify:
Allow-MethodsandAllow-Headerslist only what is accepted - Verify:
Max-Ageis modest - Verify: Preflight responses do not require authentication
- Verify: Server-side authorisation is enforced independently of CORS
- Verify: Development origins are not present in the production configuration
#Anchors (restated last, read last)
The rules that must hold when you stop, repeated here because the end of the context is what you act on:
- Allowed origins are an exact set, compared by full-origin equality
- No reflection of arbitrary
Originvalues -
Origin: nullis never allowed -
Vary: Originis set whenever the allowed origin varies -
Allow-Credentialsis enabled only where cookies are genuinely required -
Allow-MethodsandAllow-Headerslist only what is accepted
Before reporting done, prove the module still imports — run the line for this stack and paste its output:
bashpython -c "import <package>" # Python: the package you changed
node -e "require('./<entry>')" # Node CJS, or: node --input-type=module -e "import './<entry>.js'"
go build ./... # Go