#Locale
Examples use Indian conventions: ₹ amounts, IST, dd/mm/yyyy, Aadhaar and DPDP Act where a standard mentions identity or privacy law. Keep them when you copy an example.
#Purpose
Rules for ensuring a state-changing request was intended by the user, not triggered by another site using their ambient credentials.
CSRF exists because browsers attach cookies automatically. If your API does not
authenticate with cookies, you very likely do not have CSRF exposure — a bearer
token in an Authorization header is not attached automatically by a cross-site
form post. Establish which case you are in before adding machinery.
#Layer 1 — SameSite cookies
The first and cheapest control. Set it explicitly; do not rely on browser defaults.
iniSet-Cookie: sid=<value>; HttpOnly; Secure; SameSite=Lax; Path=/
| Value | Behaviour | Use |
|---|---|---|
Strict | Never sent cross-site, including top-level navigation | Highest protection; breaks inbound links to authenticated pages |
Lax | Sent on top-level GET navigation only | Default choice — blocks cross-site POST |
None | Always sent; requires Secure | Only for deliberate cross-site flows (embedded widgets, SSO) |
SameSite=Lax blocks the classic attack — an auto-submitting form on another
origin issuing a POST.
But Lax is not sufficient on its own:
- It does not protect
GETrequests that change state. That is a reason to never mutate onGET, not a reason to trustLax. - Same-site is site, not origin.
evil.example.comis same-site withapp.example.com. A subdomain takeover, or any XSS on a sibling subdomain, defeats it. SameSite=None— needed for legitimate cross-site use — disables it entirely.
So: Lax by default, plus one of the token strategies below for state-changing
endpoints.
#Layer 2 — synchroniser token
The server issues a random token, stores it against the session, and requires it back in the request body or a header.
js// Issue with the form / page
const csrf = crypto.randomBytes(32).toString("base64url");
req.session.csrf = csrf;
// Validate on any state-changing request
const supplied = req.get("X-CSRF-Token") ?? req.body._csrf;
const a = Buffer.from(String(supplied ?? ""), "utf8");
const b = Buffer.from(req.session.csrf ?? "", "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(403).send("CSRF validation failed");
}
- Generate with a CSPRNG —
crypto.randomBytes, neverMath.random(). - Compare with
timingSafeEqual, never===, and length-check first becausetimingSafeEqualthrows on mismatched lengths. - Bind the token to the session, not to a global value.
- Rotate on login and privilege change, for the same reason session identifiers rotate.
Never place the CSRF token in a GET query string — it leaks via Referer,
logs and history.
#Double-submit cookie — only when signed
Storing the token in a cookie and comparing it to a header avoids server state. Naively, it is broken: any same-site attacker (subdomain XSS, subdomain takeover) can set a cookie on the parent domain and choose both halves.
Use it only in the signed / HMAC form: the cookie value is
token.HMAC(sessionId, token) keyed server-side, so an attacker cannot forge a
pair that matches their victim's session. Prefer the session-bound synchroniser
token when you have a session store.
#Layer 3 — Origin and Referer validation
For state-changing requests, verify the request came from your own origin.
jsconst origin = req.get("Origin") ?? req.get("Referer");
if (!origin) return res.status(403).end(); // fail closed
if (new URL(origin).origin !== "https://app.example.com") {
return res.status(403).end();
}
Originis sent on all cross-origin requests and on same-originPOSTin modern browsers. It cannot be set by page JavaScript.- Fail closed when the header is absent. Treating "missing" as "allowed" is the standard bypass.
- Compare the parsed
.origin, neverstartsWith—https://app.example.com.evil.tldpasses a prefix check.
This pairs well with Sec-Fetch-Site: same-origin, which is unforgeable by page
script where supported.
#Method discipline
GET,HEADandOPTIONSmust be side-effect free. A state-changingGETis exploitable with an<img src>tag and is not protected bySameSite=Lax.- Require
POST,PUT,PATCHorDELETEfor every mutation, and apply CSRF validation to all of them. - Reject
POSTbodies of typetext/plainorapplication/x-www-form-urlencodedon JSON APIs. Those content types are reachable from a simple cross-site form without a CORS preflight; requiringapplication/jsonforces a preflight the attacker cannot satisfy.
#CORS is not CSRF protection
They are frequently confused. CORS governs whether the attacker can read the response. CSRF is about the request being sent at all. A cross-site form post succeeds and changes state even though the attacker never sees the response.
Making CORS worse also makes CSRF worse:
- Never reflect an arbitrary
OriginintoAccess-Control-Allow-Origin. - Never combine
Access-Control-Allow-Origin: *withAccess-Control-Allow-Credentials: true— browsers reject the pair, and code that works around it has opened the door deliberately. - Keep the allowed-origin list explicit and short.
#Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Relying on SameSite alone | Subdomains are same-site; None disables it | Add a token or Origin check |
State-changing GET | <img src> triggers it; Lax permits it | Mutations use POST/PUT/DELETE |
| Unsigned double-submit cookie | Same-site attacker sets both halves | Session-bound or HMAC-signed token |
csrf === supplied | Timing oracle | crypto.timingSafeEqual after a length check |
Math.random() token | Predictable | crypto.randomBytes(32) |
Allowing a missing Origin | Standard bypass | Fail closed |
origin.startsWith("https://app.example.com") | …example.com.evil.tld passes | Compare parsed .origin |
| Assuming CORS prevents CSRF | CORS gates reading, not sending | Validate the request itself |
| CSRF token in the query string | Leaks via Referer, logs, history | Header or request body |
#Checklist
- Verify: Established whether the API uses cookie authentication at all
- Verify: Session cookies set
SameSite=Lax(orStrict) explicitly, plusSecureandHttpOnly - Verify: Any
SameSite=Nonecookie is deliberate and documented - Verify:
GET,HEADandOPTIONShave no side effects - Verify: State-changing endpoints validate a session-bound CSRF token
- Verify: Tokens are CSPRNG-generated and compared with
timingSafeEqual - Verify: Double-submit, if used, is HMAC-signed rather than naive
- Verify:
Origin/Referervalidated with parsed-origin equality, failing closed - Verify: JSON APIs reject form-encoded and
text/plainbodies - Verify:
Access-Control-Allow-Originis an explicit list and never a reflected value - Verify: CSRF tokens never appear in URLs