CORS & Security Headers - Same-Origin Policy, Misconfigurations, HSTS & CSP
The same-origin policy stops a script on another site from reading your responses. CORS is the opt-in that relaxes that rule. It never adds protection. Reflecting any Origin together with credentials lets any site read a logged-in user's data. A short header baseline closes clickjacking, MIME sniffing, and SSL stripping beside that.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Explain the same-origin policy and why CORS exists.
Answer
SOP stops scripts from reading cross-origin responses, which protects logged-in data from other sites. CORS lets a server opt specific origins in, for example a SPA on `app.example.com` calling `api.example.com`.
L2
When does the browser send a preflight?
Answer
For non-simple requests: methods other than GET, HEAD, or POST; custom headers like `Authorization`; or `Content-Type` other than form, multipart, or `text/plain` (so JSON triggers it).
L3
Why can't you use `*` with credentials?
Answer
The spec forbids it so a server can't accidentally share credentialed responses with every site. The server must echo one exact origin and add `Vary: Origin`.
L4
A pentest reports "CORS misconfiguration: origin reflection". What's the real risk?
Answer
If credentials are allowed, any website a logged-in user visits can read their API responses (PII, tokens, CSRF tokens). Fix with an exact allowlist and check whether sensitive data was exposed.
L5
What does HSTS preload do?
Answer
Browsers ship with your domain hard-coded as HTTPS-only, protecting even the very first visit. Removal takes months, so make sure every subdomain supports HTTPS before submitting.
L6
Which headers would you add to a new service on day one?
Answer
HSTS, a nonce-based CSP (Report-Only first), `X-Content-Type-Options: nosniff`, `frame-ancestors 'none'`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy` for unused features, and `__Host-` session cookies with `Secure`, `HttpOnly`, and `SameSite`.
Failure modes
Reflect any Origin with credentials
Any website can read a logged-in user's API responses.
Suffix or regex host check
evilexample.com and example.com.evil.com pass a lazy matcher.
Allowing null
Sandboxed iframes and file URLs send Origin null.
Missing Vary Origin
A CDN caches one caller's CORS header and serves it to the next caller.
Misconceptions
CORS stops other sites from calling the API.
Simple requests are sent. CORS decides whether script may read the response. Non-browsers ignore it.
Access-Control-Allow-Origin star is always dangerous.
It is fine for public data without cookies. The browser refuses star together with credentials.
Redirecting HTTP to HTTPS replaces HSTS.
The first cleartext request can be rewritten before the redirect. HSTS makes the browser skip HTTP.
Interviewer traps
Using CORS as the authorization story.
Say the browser rule out loud, then say every endpoint still authenticates and authorizes the caller.
Adding X-XSS-Protection because it sounds like XSS.
It is deprecated. Use a nonce CSP. The XSS page has the policy.
Design scenario
Same prompt for every reader.
Requirements
The SPA can read credentialed responses. The docs site can read the status endpoint. No other origin can read credentialed bodies. Caches must not mix CORS headers.
Traffic / scale
The status endpoint is cached. The credentialed API is not.
Latency
Preflight is cached in the browser for minutes, not days, until the allowlist is stable.
Consistency
One Allow-Origin value per response, generated in one place.
Availability
A CORS denial hides the body from script. The request still needs a normal authz check so curl cannot skip it.
Failure assumptions
- A response echoes a partner origin with credentials.
- The status cache stores a CORS header.
Constraints
- Credentials and star are never combined.
- HSTS is on both hostnames before any preload submission.
Prompt
A SPA on https://app.example.com calls https://api.example.com with cookies. A public docs site reads a cookie-less status endpoint. Partners are not allowed to call the API from their origins yet.
API
Which routes are credentialed, and which route is public?
Data
What is the allowlist, and does it include null or a suffix?
Architecture
Does the app or the gateway set CORS, and who sets Vary?
Relaxing the same-origin policy on purpose
Prefer
Exact origin allowlist, plus Vary
Credentialed responses echo one origin you configured. Everyone else gets no CORS header and the browser hides the body from script.
- Scheme, host, and port match as strings.
- null is not on the list.
- The header baseline does not depend on CORS.
Alternative
Echo the Origin header
Any site a logged-in user visits can read the response if credentials are allowed.
- endswith example.com matches evilexample.com.
- A sandboxed iframe sends Origin null.
- A cache without Vary Origin replays one site's header to another.
A credentialed cross-origin PUT
The browser asks permission before it lets script read. curl never asks.
- 1
Page calls fetch
app.example.com sends a JSON PUT with credentials. - 2
Preflight
The browser OPTIONS with Origin and the method. JSON and PUT are not simple. - 3
Server answers one origin
Allow-Origin is the exact page origin. Allow-Credentials is true. Vary is Origin. - 4
Actual request
The browser sends the PUT with cookies only after the preflight matches. - 5
Script may read
Only if the response repeats that exact origin. A mismatch hides the body.
Overview
The browser's same-origin policy (SOP) stops JavaScript on evil.com from reading responses from bank.com. CORS (Cross-Origin Resource Sharing) is the opt-in mechanism that relaxes that rule for origins you choose. That's the key mental model: CORS never adds protection, it only removes it. A misconfigured CORS policy, such as reflecting any Origin with Access-Control-Allow-Credentials: true, lets any website read your users' private API responses. Alongside CORS, a small set of security response headers (HSTS, CSP, X-Content-Type-Options, frame controls, Referrer-Policy) closes whole classes of attacks for free.
HSTS and the TLS session it sticks to are HTTP versions and TLS. The routes those headers sit on are API design. CORS is not a substitute for authorization on those routes.
What CORS does and doesn't do
| Question | Answer |
|---|---|
| Does CORS stop a cross-site request from being sent? | No. Simple requests are sent; CORS decides whether JS may read the response. Preflighted requests are blocked before sending if the preflight fails. |
| Does CORS protect against CSRF? | Only indirectly, for requests that need a preflight. Use CSRF defenses. |
Does CORS apply to curl or server-to-server calls? | No, it's a browser rule. Your API still needs authentication and authorization. |
Is Access-Control-Allow-Origin: * dangerous? | Not for public, cookie-less data. The browser refuses * together with credentials. |
| What's the dangerous combination? | Reflecting arbitrary origins (or null) and Allow-Credentials: true. |
Sequence
- 1
Page on app.example.com → Browser
1. fetch PUT /profile with JSON and credentials
- 2
Browser → api.example.com
2. Preflight OPTIONS with Origin and requested method
- 3
api.example.com → Browser
3. Allow-Origin app.example.com, Allow-Methods PUT, Allow-Credentials true
- 4
Browser → api.example.com
4. Actual PUT with cookies
- 5
api.example.com → Browser
5. Response with the same CORS headers
- 6
Browser → Page on app.example.com
6. JS may read the response only if headers match exactly
Lesson map
CORS & Security Headers - Same-Origin Policy, Misconfigurations, HSTS & CSP
The same-origin policy stops a script on another site from reading your responses. CORS is the opt-in that relaxes that rule. It never adds protection. Reflecting any Origin together with credentials lets any site read a logged-in user's data. A short header baseline closes clickjacking, MIME sniffing, and SSL stripping beside that.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB p["Page on app.example.com"] b["Browser"] a["api.example.com"] p -->|1. fetch PUT /profile with JSON and credentials| b b -->|2. Preflight OPTIONS with Origin and requested method| a a -->|3. Allow-Origin app.example.com, Allow-Methods PUT, Allow-Credentials true| b b -->|4. Actual PUT with cookies| a a -->|5. Response with the same CORS headers| b b -->|6. JS may read the response only if headers match exactly| p
Common CORS bugs, side by side
# CORS decisions: why "reflect any Origin + credentials" is a data leak,
# and what a strict allowlist answers instead.
ALLOWLIST = {"https://app.example.com", "https://admin.example.com"}
def reflect_everything(origin): # common bug
return {"Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true"}
def suffix_check(origin): # another bug: endswith is not a host check
ok = origin.endswith("example.com")
return {"Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true"} if ok else {}
def strict(origin):
if origin in ALLOWLIST: # exact match, scheme included; "null" never allowed
return {"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true", "Vary": "Origin"}
return {}
def browser_lets_js_read(origin, h):
# With credentials, the browser requires an EXACT origin echo (never "*").
return h.get("Access-Control-Allow-Origin") == origin and h.get("Access-Control-Allow-Credentials") == "true"
print(f"{'origin':30} reflect suffix strict")
for o in ["https://app.example.com", "https://evil.com", "https://evilexample.com", "null"]:
r = [browser_lets_js_read(o, f(o)) for f in (reflect_everything, suffix_check, strict)]
print(f"{o:30} {'READ' if r[0] else '-':8} {'READ' if r[1] else '-':7} {'READ' if r[2] else '-'}")Output:
origin reflect suffix strict
https://app.example.com READ READ READ
https://evil.com READ - -
https://evilexample.com READ READ -
null READ - -| Bug | Why it happens | Fix |
|---|---|---|
Reflect any Origin with credentials | "Quick fix" for local dev pushed to prod | Exact-match allowlist from config |
Suffix or regex match (endswith("example.com"), unanchored regex) | Lazy matching | Parse the origin; compare scheme + host + port exactly |
Allowing null origin | Sandboxed iframes and file:// send null | Never allowlist null |
| Trusting all subdomains | Any subdomain XSS or takeover becomes API read access | Only list subdomains that need it |
Missing Vary: Origin | CDN caches one origin's CORS headers and serves them to everyone | Always send Vary: Origin when the header is dynamic |
Long Access-Control-Max-Age on a broad policy | Mistakes are cached in browsers | Keep it modest (minutes to hours) |
Security headers baseline
// Audit a response's security headers against a baseline.
const baseline: Record<string, (v: string | undefined) => string | null> = {
"strict-transport-security": (v) => (v && /max-age=(\d+)/.test(v) && Number(/max-age=(\d+)/.exec(v)![1]) >= 31536000 ? null : "need max-age>=1y (HSTS)"),
"content-security-policy": (v) => (v && !/unsafe-inline/.test(v) ? null : "missing CSP or uses 'unsafe-inline'"),
"x-content-type-options": (v) => (v === "nosniff" ? null : "set nosniff (stops MIME sniffing)"),
"referrer-policy": (v) => (v ? null : "set strict-origin-when-cross-origin"),
"x-frame-options": (v) => (v === "DENY" || v === "SAMEORIGIN" ? null : "clickjacking: DENY or CSP frame-ancestors"),
};
function audit(name: string, headers: Record<string, string>): void {
const lower: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
const problems = Object.entries(baseline)
.map(([h, check]) => [h, check(lower[h])] as const)
.filter(([, p]) => p !== null);
console.log(`${name}: ${problems.length === 0 ? "PASS" : problems.length + " issue(s)"}`);
for (const [h, p] of problems) console.log(` - ${h}: ${p}`);
}
audit("legacy app", { "X-Powered-By": "Express", "Content-Security-Policy": "script-src 'self' 'unsafe-inline'" });
audit("hardened app", {
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
"Content-Security-Policy": "script-src 'nonce-abc' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Frame-Options": "DENY",
});Output:
legacy app: 5 issue(s)
- strict-transport-security: need max-age>=1y (HSTS)
- content-security-policy: missing CSP or uses 'unsafe-inline'
- x-content-type-options: set nosniff (stops MIME sniffing)
- referrer-policy: set strict-origin-when-cross-origin
- x-frame-options: clickjacking: DENY or CSP frame-ancestors
hardened app: PASS| Header | Stops | Recommended value | Gotcha |
|---|---|---|---|
Strict-Transport-Security | SSL stripping, cookie leaks over HTTP | max-age=63072000; includeSubDomains; preload | Preload is hard to undo; verify every subdomain serves HTTPS first |
Content-Security-Policy | XSS, data exfiltration, clickjacking (frame-ancestors) | Nonce-based script-src, object-src 'none', base-uri 'none' | Start in Report-Only |
X-Content-Type-Options: nosniff | MIME sniffing turning uploads into scripts | nosniff | Serve correct Content-Type too |
X-Frame-Options / frame-ancestors | Clickjacking | DENY or CSP frame-ancestors 'none' | CSP version supersedes XFO in modern browsers |
Referrer-Policy | Leaking tokens or paths in the Referer header | strict-origin-when-cross-origin | Never put secrets in URLs anyway |
Permissions-Policy | Unwanted camera, mic, geolocation use by embedded content | Disable unused features | Syntax changed from Feature-Policy |
Cross-Origin-Opener-Policy / Cross-Origin-Resource-Policy | Cross-window attacks, Spectre-style leaks | same-origin where possible | Can break OAuth popups; test flows |
Cookie flags (Secure, HttpOnly, SameSite, __Host- prefix) | Theft over HTTP, JS reads, CSRF, subdomain overrides | __Host-session=...; Secure; HttpOnly; SameSite=Lax; Path=/ | __Host- forbids a Domain attribute |
Also remove headers that only help attackers fingerprint you: X-Powered-By, detailed Server versions, and stack traces in error bodies.
What happens if you choose differently
- Set CORS in both the app and the API gateway: duplicate
Access-Control-Allow-Originheaders make browsers reject the response, so teams "fix" it by loosening one layer. Pick one owner. - Use CORS as authorization: non-browser clients ignore it, so every endpoint still needs real auth.
- Skip HSTS because "we redirect HTTP to HTTPS": the first plain-HTTP request can be intercepted before the redirect; HSTS makes the browser never try HTTP.
- Rely on
X-XSS-Protection: it's deprecated and removed from modern browsers; some old implementations even introduced leaks. Use CSP.
Curl a credentialed API with Origin https://evil.example and with Origin null. If the response echoes either origin and sets Allow-Credentials, that is the finding. Then diff the security headers against the baseline table.
Interview Q&A
Explain the same-origin policy and why CORS exists.
Answer
SOP stops scripts from reading cross-origin responses, which protects logged-in data from other sites. CORS lets a server opt specific origins in, for example a SPA on app.example.com calling api.example.com.
When does the browser send a preflight?
Answer
For non-simple requests: methods other than GET, HEAD, or POST; custom headers like Authorization; or Content-Type other than form, multipart, or text/plain (so JSON triggers it).
Why can't you use `*` with credentials?
Answer
The spec forbids it so a server can't accidentally share credentialed responses with every site. The server must echo one exact origin and add Vary: Origin.
A pentest reports "CORS misconfiguration: origin reflection". What's the real risk?
Answer
If credentials are allowed, any website a logged-in user visits can read their API responses (PII, tokens, CSRF tokens). Fix with an exact allowlist and check whether sensitive data was exposed.
What does HSTS preload do?
Answer
Browsers ship with your domain hard-coded as HTTPS-only, protecting even the very first visit. Removal takes months, so make sure every subdomain supports HTTPS before submitting.
Which headers would you add to a new service on day one?
Answer
HSTS, a nonce-based CSP (Report-Only first), X-Content-Type-Options: nosniff, frame-ancestors 'none', Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy for unused features, and __Host- session cookies with Secure, HttpOnly, and SameSite.