Frontend / Security / 04_cors_security_angle.md

CORS — Security Implications

Updated 5 interview angles 7 min read source
On this page15
  1. Is CORS a security feature?
  2. The canonical CORS bug — Access-Control-Allow-Origin: * with credentials.
  3. Why is Vary: Origin critical?
  4. Trusting the Origin header for authorization — bad.
  5. Public APIs with Access-Control-Allow-Origin: *.
  6. Credentials in CORS — what to know.
  7. Subdomain CORS — wildcard origins?
  8. CORS + Authorization header.
  9. CORS preflight cache poisoning.
  10. Common patterns and their security implications.
  11. How do you actually test CORS config?
  12. Gotchas
  13. Related
  14. Further reading
  15. Interview angle

CORS — Security Implications

CORS is not a security feature — it’s a mechanism that lets servers opt into relaxing the same-origin policy. The browser enforces SOP by default; CORS is the opt-out. Misconfigured CORS is one of the most common security vulnerabilities in modern APIs. The senior knowledge: Access-Control-Allow-Origin: * + credentials is the canonical fatal bug; echoing back arbitrary origins is the second; trusting the Origin header for authorization is the third.

(Protocol mechanics in CORS at the Protocol Level — Preflight, Credentials, Exposed Headers; this file is security-focused.)

Is CORS a security feature?

No. This is the misconception interviewers test for.

  • Same-Origin Policy (SOP) is the security mechanism — browsers enforce it by default to prevent https://evil.com from reading https://bank.com’s responses.
  • CORS is the opt-out — a server explicitly tells the browser “this resource is safe to share with origin X.”

CORS configuration controls how relaxed SOP is. A wrong CORS config doesn’t break security — it removes security.

The threat model: malicious JavaScript on evil.com wants to read your API responses (auth-gated). SOP blocks the read by default. CORS misconfiguration lets the read through.

The canonical CORS bug — Access-Control-Allow-Origin: * with credentials.

Browsers refuse to honor Allow-Origin: * with credentialed requests (cookies, Authorization header). The combination doesn’t even work.

But the trap: developers echo back arbitrary origins to work around this:

ts
// Naive server
const origin = req.headers.origin;
// any origin gets echoed
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");

Now https://evil.com can:

  1. Trick a logged-in user to visit evil.com.
  2. evil.com’s JS does fetch("https://api.yoursite.com/me", { credentials: "include" }).
  3. Browser sends cookies; server returns Allow-Origin: https://evil.com.
  4. evil.com reads the user’s data.

Fix: maintain an allowlist of permitted origins; only echo if the request’s origin is in the list:

ts
const ALLOWED = ["https://app.example.com", "https://admin.example.com"];
const origin = req.headers.origin;
if (ALLOWED.includes(origin)) {
  res.setHeader("Access-Control-Allow-Origin", origin);
  res.setHeader("Vary", "Origin");
}
res.setHeader("Access-Control-Allow-Credentials", "true");

Vary: Origin so CDN doesn’t cache one origin’s response for another.

Why is Vary: Origin critical?

Without it, a CDN may cache the response with Allow-Origin: https://app.example.com and serve it to a request from https://other.example.com, which would then be blocked by the browser (mismatched origin).

Worse case: CDN serves a response with Allow-Origin: * to a credentialed-context request. Now any origin can read it.

Vary: Origin tells the CDN: cache per origin. Each origin gets its correctly-tailored response.

Trusting the Origin header for authorization — bad.

Some servers check req.headers.origin and grant access based on it:

ts
// BAD — origin header used for auth decision
if (req.headers.origin === "https://app.example.com") {
  return res.json(sensitiveData);
}

Origin is set by the browser in cross-origin contexts. For:

  • Non-browser clients (curl, scripts, server-to-server): Origin is missing or anything the client wants.
  • Same-origin requests from a browser: Origin may be missing (older specs).
  • Spoofing: a malicious server can send any Origin header.

Origin is informational; never authorization. Real auth uses cookies/tokens, not headers the client sets.

Public APIs with Access-Control-Allow-Origin: *.

Acceptable when:

  • The API is truly public (no auth, no per-user data).
  • No credentials are involved (no cookies, no Authorization header).
  • The response contains nothing the SOP was protecting.

Examples: weather APIs, public CDN content, public chart data. The wildcard says “anyone can read.” If your data needs auth, you don’t want *.

Credentials in CORS — what to know.

Client side:

ts
fetch(url, { credentials: "include" });          // send cookies cross-origin
fetch(url, { credentials: "same-origin" });      // default — only same-origin
fetch(url, { credentials: "omit" });             // never send cookies

Server side:

http
Access-Control-Allow-Origin: https://app.example.com    # NEVER *
Access-Control-Allow-Credentials: true

Both sides must opt in. If either is missing, cookies aren’t sent or the response is blocked.

Security implication: credentialed CORS exposes more risk because cookies attach automatically. Be conservative with the origin allowlist.

Subdomain CORS — wildcard origins?

A server with many subdomains (*.example.com):

ts
const ALLOWED_PATTERN = /^https:\/\/[a-z0-9-]+\.example\.com$/;
if (ALLOWED_PATTERN.test(req.headers.origin)) {
  res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
  res.setHeader("Vary", "Origin");
}

Caveat: any subdomain takeover (a dangling DNS record pointing to an unclaimed service) becomes an attacker-controlled origin in your allowlist. Audit DNS regularly.

CORS + Authorization header.

Authorization: Bearer <token> is a non-simple header → triggers preflight. Server must allow it:

http
Access-Control-Allow-Headers: Authorization, Content-Type

Without this, the browser blocks the request after the preflight.

Bearer tokens (in Authorization header, set by JS) are CSRF-safe (not auto-attached), but XSS-vulnerable (XSS reads them from memory/storage). httpOnly cookies are the opposite trade-off. See Token Storage — JWT in localStorage vs httpOnly Cookie.

CORS preflight cache poisoning.

A subtle attack: server’s preflight response varies based on something other than Origin (e.g., Cookie from a logged-in user). Browser caches the preflight; subsequent unauthenticated request hits the cached “I’m allowed” preflight.

Defense: don’t vary preflight response by anything user-specific. The preflight should be deterministic per origin + URL + method. If it must vary, set Access-Control-Max-Age: 0.

Common patterns and their security implications.

Pattern Security
Allow-Origin: * + no credentials ok for public data
Allow-Origin: * + credentials browser refuses; can’t even ship
Allow-Origin: <echo> + credentials, no allowlist dangerous — any origin reads your data
Allow-Origin: <echo from allowlist> + credentials safe
Allow-Origin: <specific origin> + credentials safe
Allow-Origin: null dangerous — null can be sent by sandboxed iframes, redirected pages
Allow-Origin: * for ranged image (e.g., for canvas) dangerous if behind auth

How do you actually test CORS config?

  1. curl from a “different origin” simulation:
    bash
    curl -i -H "Origin: https://evil.com" https://api.example.com/me
    Look at Access-Control-Allow-Origin in the response.
  2. Test in a browser with a malicious-origin test page (a local file or test server).
  3. Burp Suite / OWASP ZAP — automated misconfig scanners.
  4. Continuous monitoring — if your CORS allowlist is dynamic (echoed from a DB), set up alerts for unusual origins being added.

Gotchas

  • Origin: null is sent by sandboxed iframes, file://, some redirects. Never include in your allowlist — attackers can craft requests with Origin: null.
  • Preflights are not encrypted differently — OPTIONS requests follow same HTTPS rules.
  • WebSocket doesn’t honor CORS the same way — the Origin header is sent; server must check it. Browser doesn’t auto-block based on response.
  • CORS doesn’t prevent CSRF — the browser may still send the request (with cookies via SameSite), it just hides the response. CSRF doesn’t need to read the response; it just needs the side effect.
  • Access-Control-Expose-Headers — JS can only read default-safe response headers + ones listed here. Custom headers like X-Total-Count need to be exposed.
  • DNS rebinding attacks can circumvent CORS by mapping an attacker-controlled hostname to internal IPs. Mitigate with Host header validation, not just CORS.

Further reading

Interview angle 5

  • “Is CORS a security feature?” - no, and this is the question. The Same-Origin Policy is the security feature; CORS is the mechanism a server uses to relax it deliberately. So a CORS misconfiguration does not add a vulnerability - it removes a protection you already had.
  • “What is the dangerous misconfiguration?” - reflecting the request’s Origin header back in Access-Control-Allow-Origin with credentials enabled. It looks like a wildcard that works with cookies, and it means every origin on the internet is allowed. Use a real allowlist.
    • Follow-up: “Why not just use *?” - the browser refuses * together with credentials, which is exactly why people reach for the reflection trick. The refusal is the specification protecting you.
  • “Can you authorize based on the Origin header?” - no. It is set by the browser, so it is absent or arbitrary from anything that is not a browser - curl, a server, a script. It tells you which page initiated a request, not who the caller is.
  • “Why does Vary: Origin matter?” - because without it a CDN or shared cache can store the response generated for one allowed origin and serve it to another. The header becomes part of the cache key, which is the only thing making per-origin responses safe to cache.
  • “Does CORS protect against CSRF?” - no, and conflating them is common. The browser still sends the cookie and the request still reaches your server; CORS only stops the attacker reading the response. CSRF never needed the response - the side effect already happened.