Web Security
Web Security

CSRF, SameSite cookies and where the session lives

Full · ~40 min
After this unit you can
  • 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, Lax and None, and which browsers apply Lax when the attribute is missing
  • Write a server-side guard that rejects cross-site state-changing requests with Sec-Fetch-Site, an exact Origin fallback, and fail-closed handling of missing headers
  • Explain what Next.js Server Actions check (POST only, Origin vs host, allowedOrigins) and what they leave to you
  • Choose where the session token lives — HttpOnly cookie or localStorage — by what an XSS can do with each

Read

Simple
1 / 2

CSRF — the browser brings the cookie along

Flag

Cross-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.example can't make the browser add that header.
  • It needs a request the attacker can predict. A POST /transfer with to and amount is 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.

Medium
1 / 2

Check where the request came from — Sec-Fetch-Site, then Origin

Flag

The 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:

ValueMeaning
same-originsame scheme, host and port as you
same-sitea sibling on the same registrable domain (uploads.example.com → app.example.com)
cross-siteanywhere else
nonethe 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 accepts https://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 Host header, take X-Forwarded-Host only 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.

Complex
1 / 2

HttpOnly cookie or localStorage — decide by what an XSS can do

Flag

Where 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 cookieToken in localStorage, sent as Authorization: Bearer
Sent automatically by the browser?yes, so CSRF is possibleno, so CSRF isn't
Readable by page JavaScript?noyes
What an XSS getsthe ability to act as the user while the tab is openthe 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 has Secure, Path=/ and no Domain, so a sibling subdomain can't set or overwrite it.
  • Plus the server-side origin check on every state-changing request, because SameSite doesn'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?

Flag

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?

Flag

Your session cookie is set without a SameSite attribute. Which statement is accurate?

Flag

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?

Flag

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?

Flag

What does Next.js 16 do on its own to protect Server Actions from CSRF?

Flag

Which of these gets no origin check from Next.js, even though it accepts cookie-authenticated POSTs?

Flag

You're choosing between an HttpOnly session cookie and a token in localStorage sent as a bearer header. Which comparison is accurate?

Flag

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.

Flag
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.

Flag
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.

Flag
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);
Flag
// 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));
Flag

Challenge

Close the holes in the CSRF guard

Flag

csrf-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:

  1. Only POST is checked. A cross-site DELETE, PUT or PATCH passes untouched. The rule is the other way round: safe methods (GET, HEAD, OPTIONS) are skipped, everything else is checked.
  2. Origin is compared with startsWith. https://app.example.com.evil.test starts with the trusted origin, and anyone can register that domain.
  3. Sec-Fetch-Site: same-site is trusted. That value means "a sibling subdomain", and one of them serves user uploads. The app's own pages always produce same-origin.
  4. No headers means "allowed". OWASP: missing headers "are not evidence of a same-origin request". Fail closed.
  5. The session cookie has no HttpOnly, Secure or SameSite. Without SameSite, Chromium applies Lax by default, but Firefox and Safari don't. Say what you mean.

Your task

Edit csrf-guard.ts so the tests pass:

  • GET, HEAD and OPTIONS pass; POST, PUT, PATCH and DELETE from another site are rejected
  • With Sec-Fetch-Site, only same-origin is accepted; same-site, cross-site and none are rejected
  • Without Sec-Fetch-Site, Origin must exactly equal a trusted origin; https://app.example.com.evil.test, https://app.example.co and null are rejected
  • A state-changing request with neither header is rejected
  • A same-origin POST is allowed
  • sessionCookie sets HttpOnly, Secure and an explicit SameSite=Lax, and keeps the value, Path=/ and Max-Age

Loading the in-browser editor…

Further reading