CSRF, SameSite cookies and where the session lives
- Explain how CSRF works — the browser attaches cookies to a request another site starts — and why a bearer-token API isn't exposed the same way
- Predict whether a cookie is sent cross-site under
SameSite=Strict,LaxandNone, and which browsers applyLaxwhen the attribute is missing - Write a server-side guard that rejects cross-site state-changing requests with
Sec-Fetch-Site, an exactOriginfallback, and fail-closed handling of missing headers - Explain what Next.js Server Actions check (POST only,
Originvs host,allowedOrigins) and what they leave to you - Choose where the session token lives —
HttpOnlycookie orlocalStorage— by what an XSS can do with each
Read
CSRF — the browser brings the cookie along
FlagCross-site request forgery needs no bug in your HTML and no script on your page. It uses one browser behaviour: cookies are attached to requests by destination, not by who started the request. (How cookie sessions and bearer tokens work as mechanisms is the Auth over HTTP unit; this one is about the attack.)
You're logged in to bank.example, so your browser holds its session cookie. You then visit a page
on evil.example that contains:
<form action="https://bank.example/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="5000" />
</form>
<script>document.forms[0].submit();</script>
The form posts to the bank. Unless the cookie's SameSite setting stops it (next reading), the
browser attaches your bank session cookie, the bank sees an authenticated request from you, and
the transfer goes through.
Three facts define the attack:
- The attacker can make the request but can't read the response. CSRF is write-only: it changes state (transfer, change email, delete) and gains nothing from what comes back.
- It needs credentials the browser adds on its own — cookies (or HTTP auth). An API that
authenticates with an
Authorization: Bearer …header that your JavaScript adds isn't exposed this way:evil.examplecan't make the browser add that header. - It needs a request the attacker can predict. A
POST /transferwithtoandamountis fully predictable; a request that must carry a secret the attacker can't know (a CSRF token) is not.
And one rule everything else builds on — OWASP: "Do not use GET requests for state changing
operations." A link, an image tag or a redirect can all trigger a GET. If GET /logout or
GET /posts/42/delete changes state, an <img src> on any site is enough.
Check where the request came from — Sec-Fetch-Site, then Origin
FlagThe server can ask the browser where a request came from, because modern browsers say so in headers that page script cannot set or change.
Sec-Fetch-Site (Fetch Metadata; in every major browser since March 2023, per MDN) describes
the relationship between the page that started the request and your server:
| Value | Meaning |
|---|---|
same-origin | same scheme, host and port as you |
same-site | a sibling on the same registrable domain (uploads.example.com → app.example.com) |
cross-site | anywhere else |
none | the user did it directly: typed URL, bookmark |
Origin carries the initiating page's scheme, host and port (never the path). Browsers send it
on cross-origin requests and on same-origin requests other than GET/HEAD (MDN), so it's
present on the state-changing requests you care about. Sandboxed frames, data: documents and a
few other cases send the literal string null.
OWASP's guidance turns these into a guard for non-safe methods (POST, PUT, PATCH,
DELETE):
const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);
const TRUSTED = new Set(["https://app.example.com"]);
function isAllowed(method: string, headers: Headers): boolean {
if (SAFE.has(method)) return true; // safe methods must not change state anyway
const site = headers.get("sec-fetch-site");
if (site) return site === "same-origin"; // add "same-site" only if you trust every subdomain
const origin = headers.get("origin"); // fallback for browsers without Fetch Metadata
if (origin) return TRUSTED.has(origin); // exact match, not origin.startsWith(…)
return false; // neither header: not evidence of same-origin
}
The details that make it correct:
- Exact comparison.
origin.startsWith("https://app.example.com")also acceptshttps://app.example.com.evil.test— a domain anyone can register. - Fail closed. OWASP: "Missing headers and Origin: null are not evidence of a same-origin request." Reject, unless another defence (a valid token) passes.
- Know your own origin behind a proxy. If you compare against the
Hostheader, takeX-Forwarded-Hostonly from a proxy you trust. Configuring the expected origin is simpler.
When to add a token as well. If the endpoint must serve browsers without these headers, or
same-site traffic can't be trusted, OWASP's token patterns still apply: the synchronizer token
(a random value stored in the session and embedded in each form) for stateful apps, or the
signed double-submit cookie for stateless ones. JSON APIs can also require a custom header
such as X-CSRF-Token: a cross-site page can't add one without a CORS preflight your server would
refuse.
HttpOnly cookie or localStorage — decide by what an XSS can do
FlagWhere the browser keeps your session token decides which attack you have to worry about. The Auth over HTTP unit covers how each option works; this is the security trade-off.
HttpOnly; Secure; SameSite cookie | Token in localStorage, sent as Authorization: Bearer | |
|---|---|---|
| Sent automatically by the browser? | yes, so CSRF is possible | no, so CSRF isn't |
| Readable by page JavaScript? | no | yes |
| What an XSS gets | the ability to act as the user while the tab is open | the token itself, usable from the attacker's own machine until it expires |
The cookie column needs CSRF defences — SameSite, the origin check, and for older browsers a
token. The localStorage column needs none, which is why it's tempting. But it converts every XSS
from "act inside the victim's tab" into "take the credential home". OWASP's HTML5 cheat sheet is
direct: "Do not store session identifiers in local storage as the data is always accessible by
JavaScript", while cookies "can mitigate this risk using the httpOnly flag".
Don't overstate HttpOnly, though. From the xss unit: a script running in your page can still
call fetch('/api/…'), and the browser still attaches the cookie. OWASP's CSRF cheat sheet:
"Cross-Site Scripting (XSS) can defeat all CSRF mitigation techniques!" — the script can read a
CSRF token from the page, and its requests are same-origin, so Sec-Fetch-Site says
same-origin. HttpOnly limits the damage (no stolen token, nothing to reuse later); only
preventing XSS prevents the attack.
A sound default for a browser app with its own backend:
Set-Cookie: __Host-session=…; Path=/; Secure; HttpOnly; SameSite=Lax
HttpOnly— script can't read it, so an XSS can't take it home.Secure— only sent over HTTPS.SameSite=Lax, written out — only Chromium applies it by default.- The
__Host-prefix — MDN: the browser rejects the cookie unless it hasSecure,Path=/and noDomain, so a sibling subdomain can't set or overwrite it. - Plus the server-side origin check on every state-changing request, because
SameSitedoesn't separate sibling subdomains.
SameSite=Strict is stronger still, at the cost of users arriving from external links looking
logged out on their first request.
Quiz
A page on evil.example mounts a CSRF attack on your cookie-authenticated app. What can it actually do?
Your SPA's API authenticates only with an Authorization: Bearer … header that your own JavaScript adds to each fetch. A cross-site page auto-submits a form to that API. What happens?
Your session cookie is set without a SameSite attribute. Which statement is accurate?
The app is on https://app.example.com, with a SameSite=Strict session cookie. User uploads are served from https://uploads.example.com. An uploaded HTML page auto-submits a form to the app. Is the cookie sent?
A POST to a cookie-authenticated endpoint arrives with neither Sec-Fetch-Site nor Origin. What does OWASP's CSRF cheat sheet say to do?
What does Next.js 16 do on its own to protect Server Actions from CSRF?
FlagWhich of these gets no origin check from Next.js, even though it accepts cookie-authenticated POSTs?
You're choosing between an HttpOnly session cookie and a token in localStorage sent as a bearer header. Which comparison is accurate?
Fill in the blank
Set a session cookie the browser will only accept with Secure, Path=/ and no Domain; hide it from page script; and say explicitly that cross-site POSTs mustn't carry it.
Set-Cookie: session=abc123; Path=/; Secure; ; SameSite=
Finish the check for a state-changing request: trust Fetch Metadata first, fall back to an exact Origin match, and fail closed.
const TRUSTED_ORIGINS = ["https://app.example.com"]; const site = req.headers[""]; if (site !== undefined) return site === ""; const origin = req.headers["origin"]; if (origin !== undefined) return TRUSTED_ORIGINS.(origin); return false;
Your Next.js 16 app sits behind a proxy whose public host is my-proxy.com, and the proxy doesn't forward that host. Let Server Actions accept requests from it and its subdomains.
module.exports = { experimental: { serverActions: { : ["my-proxy.com", "*.my-proxy.com"], }, }, };
Predict the output
const trusted = "https://app.example.com"; const origins = [ "https://app.example.com", "https://app.example.com.evil.test", "https://app.example.com:8443", "null", ]; console.log(origins.filter((o) => o.startsWith(trusted)).length);
// Next.js 16.2's own matcher for serverActions.allowedOrigins
import { isCsrfOriginAllowed } from "next/dist/server/app-render/csrf-protection.js";
const allowed = ["*.my-proxy.com"];
console.log(isCsrfOriginAllowed("app.my-proxy.com", allowed));
console.log(isCsrfOriginAllowed("my-proxy.com", allowed));
console.log(isCsrfOriginAllowed("eu.app.my-proxy.com", allowed));Challenge
Close the holes in the CSRF guard
Flagcsrf-guard.ts holds the check every state-changing request to the app goes through, and the
Set-Cookie value for the session it protects. The app's pages all live on
https://app.example.com, and every legitimate write comes from those pages. The preview runs the
guard against a handful of requests.
It stops the textbook attack, a cross-site POST, but leaves five gaps:
- Only
POSTis checked. A cross-siteDELETE,PUTorPATCHpasses untouched. The rule is the other way round: safe methods (GET,HEAD,OPTIONS) are skipped, everything else is checked. Originis compared withstartsWith.https://app.example.com.evil.teststarts with the trusted origin, and anyone can register that domain.Sec-Fetch-Site: same-siteis trusted. That value means "a sibling subdomain", and one of them serves user uploads. The app's own pages always producesame-origin.- No headers means "allowed". OWASP: missing headers "are not evidence of a same-origin request". Fail closed.
- The session cookie has no
HttpOnly,SecureorSameSite. WithoutSameSite, Chromium appliesLaxby default, but Firefox and Safari don't. Say what you mean.
Your task
Edit csrf-guard.ts so the tests pass:
GET,HEADandOPTIONSpass;POST,PUT,PATCHandDELETEfrom another site are rejected- With
Sec-Fetch-Site, onlysame-originis accepted;same-site,cross-siteandnoneare rejected - Without
Sec-Fetch-Site,Originmust exactly equal a trusted origin;https://app.example.com.evil.test,https://app.example.coandnullare rejected - A state-changing request with neither header is rejected
- A same-origin
POSTis allowed sessionCookiesetsHttpOnly,Secureand an explicitSameSite=Lax, and keeps the value,Path=/andMax-Age
Further reading
- OWASP Cheat Sheet: Cross-Site Request Forgery Prevention
- OWASP Cheat Sheet: HTML5 Security (Local Storage)
- MDN: Set-Cookie (SameSite, HttpOnly, cookie prefixes)
- MDN: Site (glossary)
- MDN: Sec-Fetch-Site
- MDN: Origin header
- MDN: Third-party cookies
- MDN browser-compat-data (SameSite Lax-by-default support)
- Next.js docs: Data Security (Server Actions, allowed origins)
- Next.js docs: serverActions (allowedOrigins)