CSRF - SameSite Cookies, Anti-CSRF Tokens & Fetch Metadata
CSRF works because the browser attaches cookies by itself. A page on another site can submit a form to yours, and the session rides along. SameSite, a CSRF token, and Origin or Sec-Fetch-Site each prove the request came from your pages. Bearer headers set by your own script are a different trade.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Why does CSRF exist at all?
Answer
Browsers attach ambient credentials (cookies) to requests no matter which site initiated them. The server can't tell intent from the cookie alone.
L2
Does CORS protect against CSRF?
Answer
Not by itself. CORS controls whether a cross-site script can *read* the response. A simple form POST is sent without a preflight, and its side effect happens regardless. CORS only helps when the request needs a preflight (custom headers, JSON content type) and you reject the preflight.
L3
Is `SameSite=Lax` enough?
Answer
It stops most cross-site POST attacks, but not GET-based side effects, not attacks from sibling subdomains (same site), and not older browsers. Pair it with tokens or Fetch Metadata checks for sensitive actions.
L4
Are bearer-token APIs vulnerable to CSRF?
Answer
No, as long as the token is attached by your code in an `Authorization` header and not stored in a cookie. They trade CSRF risk for XSS token-theft risk.
L5
What's wrong with a naive double-submit cookie?
Answer
If an attacker can set cookies for your domain (via a compromised subdomain or HTTP on a sibling host), they can set both cookie and form value to the same chosen value. Signing the token with a server key and binding it to the session fixes that.
L6
How would you add CSRF protection to a large legacy app quickly?
Answer
Set `SameSite=Lax` explicitly on session cookies, add Fetch Metadata middleware in log-only mode, review the logs for legitimate cross-site flows (SSO callbacks, payment redirects), allowlist those, then enforce. Add tokens to the highest-risk forms.
Failure modes
POST-only as the defense
A cross-site HTML form is a POST. The browser will send it.
GET with a side effect
Lax cookies are attached on a top-level link. An image tag can hit a None cookie.
CSRF middleware off for APIs
The SPA still sends the session cookie. The API is forgeable again.
Unsigned double-submit
A sibling subdomain that can set cookies can set both the cookie and the form field.
Misconceptions
CORS stops CSRF.
CORS decides whether script can read the response. A simple form POST is sent anyway, and the side effect happens.
SameSite=Lax is the whole fix.
It misses GET side effects, sibling subdomains, and browsers that do not send the attribute.
Bearer tokens have the same CSRF problem.
Another site cannot make the browser add an Authorization header your script sets. The new risk is XSS stealing that token.
Interviewer traps
Saying SameSite=None because a third-party widget needs the cookie, and stopping.
None requires Secure and a token. Lax is the default for a first-party session.
Confusing same-site with same-origin.
attacker.example.com and app.example.com are the same site. A subdomain takeover bypasses SameSite.
Design scenario
Same prompt for every reader.
Requirements
Cross-site POST must not move money. The payment return GET must still find the user logged in. A sibling subdomain must not be able to forge the token.
Traffic / scale
Transfers are rare. Page views are the bulk.
Latency
The token check is a compare against a value already in the session.
Consistency
The token is bound to the session id. A token from another session fails.
Availability
A missing Sec-Fetch-Site header falls back to Origin. It does not fail open on POST.
Failure assumptions
- evil.com auto-submits a transfer form.
- A compromised subdomain tries to plant a cookie.
Constraints
- The session cookie is Lax, Secure, and HttpOnly.
- Login has the same check.
Prompt
A bank session cookie is used by the first-party app and by a payment return that lands with a top-level GET. A marketing site on another registrable domain embeds nothing.
API
Which routes are unsafe methods, and which GET is allowed from another site?
Data
Is the CSRF token stored in the session or recomputed with an HMAC?
Architecture
Where does Fetch Metadata run relative to the handler?
Proving the user meant this request
Prefer
SameSite plus a token or Fetch Metadata
The browser withholds the cookie, or the server demands a value the other site cannot read.
- Lax blocks the classic cross-site POST.
- A signed token is bound to the session.
- Sec-Fetch-Site labels the request so one middleware can deny it.
Alternative
We only accept POST
HTML forms POST across sites. The cookie still rides along when SameSite allows it.
- Method checks do not prove intent.
- GET handlers that change state are forgeable with Lax cookies.
- Disabling the framework check on API routes reopens the hole.
A forged transfer
The attacker never sees the response. The side effect is the whole attack.
- 1
Victim is logged in
bank.com set a session cookie. - 2
Victim opens evil.com
The page holds a form aimed at bank.com/transfer. - 3
Browser decides the cookie
Strict sends nothing. Lax sends it only on a top-level safe navigation. None sends it. - 4
Server demands intent
Token, Origin, or Sec-Fetch-Site. Missing or cross-site without a GET navigation is a 403. - 5
Login form too
Login CSRF plants the attacker's session under the victim.
Overview
Cross-site request forgery (CSRF) abuses the fact that browsers attach cookies automatically. If you're logged into bank.com and visit evil.com, a hidden form on evil.com can POST to bank.com/transfer, and your session cookie rides along. The server sees a valid session and does the transfer. The attacker never reads the response; they only need the side effect. Defenses work by proving the request came from your own pages: SameSite cookies (the browser won't attach the cookie cross-site), anti-CSRF tokens (the attacker can't read or guess them), and Origin / Sec-Fetch-Site checks (the browser tells you where the request came from).
CSRF only applies when the browser sends credentials automatically: cookies, HTTP Basic auth, or client certificates. An API that requires an Authorization: Bearer header set by your own JS is not CSRF-able, because another site can't make the browser add that header.
Sequence
- 1
Victim browser → bank.com
1. Logs in, gets session cookie
- 2
Victim browser → evil.com
2. Visits attacker page
- 3
evil.com → Victim browser
3. Hidden form auto-submits POST /transfer
- 4
Victim browser → bank.com
4. POST /transfer with cookie if SameSite allows
- 5
bank.com
5. Server checks token, Origin, or Sec-Fetch-Site
- 6
bank.com → Victim browser
6. 403 if the request is cross-site without a valid token
Lesson map
CSRF - SameSite Cookies, Anti-CSRF Tokens & Fetch Metadata
CSRF works because the browser attaches cookies by itself. A page on another site can submit a form to yours, and the session rides along. SameSite, a CSRF token, and Origin or Sec-Fetch-Site each prove the request came from your pages. Bearer headers set by your own script are a different trade.
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 v["Victim browser"] e["evil.com"] b["bank.com"] v -->|1. Logs in, gets session cookie| b v -->|2. Visits attacker page| e e -->|3. Hidden form auto-submits POST /transfer| v v -->|4. POST /transfer with cookie if SameSite allows| b b -->|6. 403 if the request is cross-site without a valid token| v
Login CSRF on an OAuth redirect, and the state parameter that stops it, is the OAuth threat model. The authorization-code flow those cookies sometimes wrap is OAuth 2.1 and OIDC.
Which requests carry the cookie?
# CSRF: which requests carry the session cookie, by SameSite mode,
# then a signed double-submit token as the explicit defense.
import hmac, hashlib, secrets
def cookie_sent(samesite, cross_site, top_level_nav, method):
if not cross_site:
return True # same-site requests always carry it
if samesite == "Strict":
return False
if samesite == "Lax": # only top-level, "safe" navigations
return top_level_nav and method == "GET"
return True # None (requires Secure): always sent
scenarios = [
("evil.com auto-submits POST form", True, True, "POST"),
("link click evil.com -> bank GET", True, True, "GET"),
("evil.com fetch() POST (no-cors)", True, False, "POST"),
("bank.com own POST", False, True, "POST"),
]
print(f"{'scenario':36} Strict Lax None")
for name, xs, nav, m in scenarios:
row = ["yes" if cookie_sent(s, xs, nav, m) else "no" for s in ("Strict", "Lax", "None")]
print(f"{name:36} {row[0]:7} {row[1]:5} {row[2]}")
# Signed double-submit: token = HMAC(server_key, session_id). The attacker can't
# read the victim's cookie or page, so they can't compute or copy the token.
KEY = secrets.token_bytes(32)
def csrf_token(session_id):
return hmac.new(KEY, session_id.encode(), hashlib.sha256).hexdigest()
def verify(session_id, header_token):
return hmac.compare_digest(csrf_token(session_id), header_token or "")
sid = "sess-123"
print("legit form token valid :", verify(sid, csrf_token(sid)))
print("forged/missing token :", verify(sid, None), verify(sid, csrf_token("sess-attacker")))Output:
scenario Strict Lax None
evil.com auto-submits POST form no no yes
link click evil.com -> bank GET no yes yes
evil.com fetch() POST (no-cors) no no yes
bank.com own POST yes yes yes
legit form token valid : True
forged/missing token : False False| SameSite | Cross-site POST form | Cross-site link (GET) | Cross-site fetch/iframe | Use when |
|---|---|---|---|---|
Strict | Not sent | Not sent (user lands logged-out) | Not sent | High-value admin/banking cookies |
Lax (Chrome default when unset) | Not sent | Sent | Not sent | Normal session cookies |
None; Secure | Sent | Sent | Sent | Embedded widgets, third-party SSO iframes; needs a token defense |
Two traps: "same-site" isn't "same-origin". attacker.example.com and app.example.com are the same site, so a takeover of any subdomain bypasses SameSite. And Chrome's Lax-by-default historically allowed cross-site top-level POSTs for two minutes after a cookie was set, so set SameSite explicitly rather than relying on the default.
Token and header defenses
| Defense | How it works | Pros | Cons |
|---|---|---|---|
| Synchronizer token | Server stores a random token in the session; forms include it; server compares | Strong, framework-standard (Django, Rails, Spring) | Needs server-side session state; tricky with caching |
| Signed double-submit cookie | Token = HMAC(key, session id), sent in cookie and header/form; server recomputes | Stateless | Naive (unsigned) version is bypassable via subdomain cookie injection |
Custom header (X-Requested-With) | Cross-site forms can't set custom headers; CORS preflight blocks fetch | Simple for JSON APIs | Relies on CORS being strict; doesn't cover form posts |
Origin / Referer check | Browser sends the initiating origin on POST | No state | Some proxies strip it; must handle missing values |
Fetch Metadata (Sec-Fetch-Site) | Browser labels requests same-origin, same-site, cross-site, none | One middleware protects the whole app | Older browsers don't send it, so keep a fallback |
Fetch Metadata lets one middleware reject cross-site requests that aren't plain link navigations:
// Server-side CSRF guard using Fetch Metadata + Origin, the modern default
// (OWASP + web.dev "resource isolation policy").
type Req = { method: string; secFetchSite?: string; origin?: string; mode?: string; dest?: string };
const TRUSTED_ORIGINS = new Set(["https://app.example.com"]);
const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);
function allow(r: Req): [boolean, string] {
if (r.secFetchSite === undefined) {
// Old browser / non-browser client: fall back to the Origin header.
if (SAFE.has(r.method)) return [true, "safe method, no metadata"];
return r.origin && TRUSTED_ORIGINS.has(r.origin) ? [true, "trusted Origin"] : [false, "unsafe method, untrusted/missing Origin"];
}
if (["same-origin", "same-site", "none"].includes(r.secFetchSite)) return [true, `sec-fetch-site=${r.secFetchSite}`];
// cross-site: only allow simple top-level GET navigations (links)
if (r.mode === "navigate" && r.method === "GET" && r.dest === "document") return [true, "cross-site link navigation"];
return [false, "cross-site and not a GET link navigation"];
}
const reqs: [string, Req][] = [
["SPA fetch POST /transfer", { method: "POST", secFetchSite: "same-origin", origin: "https://app.example.com" }],
["evil.com form POST /transfer", { method: "POST", secFetchSite: "cross-site", origin: "https://evil.com", mode: "navigate", dest: "document" }],
["evil.com <img src=/delete?id=7>", { method: "GET", secFetchSite: "cross-site", mode: "no-cors", dest: "image" }],
["link from email to /inbox", { method: "GET", secFetchSite: "cross-site", mode: "navigate", dest: "document" }],
["curl POST without headers", { method: "POST" }],
];
for (const [name, r] of reqs) {
const [ok, why] = allow(r);
console.log(`${name.padEnd(34)} ${ok ? "ALLOW" : "DENY "} (${why})`);
}Output:
SPA fetch POST /transfer ALLOW (sec-fetch-site=same-origin)
evil.com form POST /transfer DENY (cross-site and not a GET link navigation)
evil.com <img src=/delete?id=7> DENY (cross-site and not a GET link navigation)
link from email to /inbox ALLOW (cross-site link navigation)
curl POST without headers DENY (unsafe method, untrusted/missing Origin)What happens if you choose differently
- "We only accept POST, so we're safe." HTML forms can POST cross-site. Only
SameSiteor a token stops it. - GET endpoints with side effects (
/logout,/delete?id=7,/subscribe):Laxcookies ride along on top-level GET navigation, and<img>tags can trigger them forNonecookies. Make state-changing operations non-GET. - Disable CSRF middleware for "API routes" while the SPA still authenticates with cookies: you've re-opened CSRF for the whole API.
- Accept JSON with any
Content-Type: an HTML form can sendtext/plainbodies that look like JSON. Requireapplication/json, which forces a CORS preflight. - Login CSRF: the attacker logs the victim into the attacker's account, so the victim's later activity (saved cards, search history) lands in the attacker's account. Protect the login form with a token too.
List every GET route that writes. Those are CSRF bugs under Lax. Then list every route that turned CSRF middleware off because it was an API, and check whether the caller is still a cookie.
Interview Q&A
Why does CSRF exist at all?
Answer
Browsers attach ambient credentials (cookies) to requests no matter which site initiated them. The server can't tell intent from the cookie alone.
Does CORS protect against CSRF?
Answer
Not by itself. CORS controls whether a cross-site script can read the response. A simple form POST is sent without a preflight, and its side effect happens regardless. CORS only helps when the request needs a preflight (custom headers, JSON content type) and you reject the preflight.
Is `SameSite=Lax` enough?
Answer
It stops most cross-site POST attacks, but not GET-based side effects, not attacks from sibling subdomains (same site), and not older browsers. Pair it with tokens or Fetch Metadata checks for sensitive actions.
Are bearer-token APIs vulnerable to CSRF?
Answer
No, as long as the token is attached by your code in an Authorization header and not stored in a cookie. They trade CSRF risk for XSS token-theft risk.
What's wrong with a naive double-submit cookie?
Answer
If an attacker can set cookies for your domain (via a compromised subdomain or HTTP on a sibling host), they can set both cookie and form value to the same chosen value. Signing the token with a server key and binding it to the session fixes that.
How would you add CSRF protection to a large legacy app quickly?
Answer
Set SameSite=Lax explicitly on session cookies, add Fetch Metadata middleware in log-only mode, review the logs for legitimate cross-site flows (SSO callbacks, payment redirects), allowlist those, then enforce. Add tokens to the highest-risk forms.