CSRF Explained: Cross-Site Request Forgery and How Modern Apps Prevent It
CSRF tricks a logged-in user's browser into making a request they didn't intend. How the attack works, what SameSite cookies do and don't cover, CSRF tokens, Origin and Fetch Metadata checks, framework defaults, and why token-in-header APIs are different.
Cross-site request forgery (CSRF) exploits the fact that browsers attach cookies automatically. If your app authenticates with a session cookie, a malicious site can make the user's browser send a request to your app — cookie included — that the user never meant to make.
The attack
A user is logged in to bank.example. They visit evil.example, which contains:
<form action="https://bank.example/transfer" method="POST" id="f">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="1000">
</form>
<script>document.getElementById("f").submit()</script>
The browser submits the form to bank.example with the user's session cookie. If the server only checks "is there a valid session?", the transfer goes through.
Key properties:
- The attacker can't read the response (same-origin policy) — CSRF is about causing state changes, not stealing data.
- It works with cookie-based auth (and HTTP Basic, client certificates) — anything the browser attaches automatically.
- It targets state-changing endpoints: transfers, email/password changes, deletes, admin actions.
SameSite cookies: the big shift
The SameSite cookie attribute controls whether cookies are sent on cross-site requests:
| Value | Cookie sent on cross-site… |
|---|---|
Strict |
Never (not even when following a link to your site) |
Lax |
Top-level navigations with safe methods (GET links), not cross-site POSTs, iframes, or fetches |
None |
Always (requires Secure) |
Chromium-based browsers treat cookies without a SameSite attribute as Lax by default, which blocks the classic form-POST attack for those browsers. (What Is a Cookie?.)
But SameSite alone isn't a complete defence:
- Set it explicitly. Not every browser applies the Lax default, and some frameworks set
SameSite=Nonefor their own reasons. - "Site" isn't "origin." SameSite considers
a.example.comandb.example.comthe same site. A vulnerable or user-controlled subdomain can mount "same-site" attacks. - GET requests that change state are still exposed under
Lax— a top-level link navigation carries the cookie. Never change state on GET. SameSite=Nonecookies (needed for some embedded/cross-site setups) get no protection.
Defence 1: CSRF tokens
The classic, robust defence: include a secret token in every state-changing request that an attacker's page can't know.
- Synchronizer token — the server stores a random token in the session and embeds it in forms (
<input type="hidden" name="csrf" …>) or a meta tag for JavaScript to send as a header. The server compares on each unsafe request. - Signed double-submit cookie — the token is set in a cookie and also sent in a header or form field; the server checks they match. Use a token cryptographically bound to the session (HMAC with a server secret), not a plain random value, so an attacker who can set cookies on a sibling subdomain can't plant a matching pair.
Tokens must be unpredictable, tied to the session, and checked on every non-GET/HEAD/OPTIONS request.
Defence 2: check Origin and Fetch Metadata
Browsers send headers that reveal where a request came from:
Origin— sent on cross-origin requests and on POSTs. Reject unsafe requests whoseOriginisn't yours.Sec-Fetch-Site—same-origin,same-site,cross-site, ornone(user-typed URL). Supported by all current major browsers.
A simple resource isolation policy:
function rejectCrossSite(req: Request) {
const site = req.headers.get("sec-fetch-site")
const unsafe = !["GET", "HEAD", "OPTIONS"].includes(req.method)
if (unsafe && site && site !== "same-origin") {
throw new Response("Forbidden", { status: 403 })
}
}
Combine with an Origin check as a fallback for clients that don't send Fetch Metadata. This is cheap, stateless, and very effective — a good layer alongside tokens and SameSite.
Framework defaults
- Django, Rails, Laravel, and ASP.NET include CSRF protection by default for form posts. Don't disable it for convenience — including via "exempt" decorators on endpoints that use cookie auth.
- Next.js Server Actions are POST-only and compare the
Originheader with the host, rejecting mismatches; if you run behind a proxy or accept requests from other origins, configure the allowed origins deliberately. (What Is Next.js?.) - Express has no built-in protection; add a maintained middleware or implement the checks above.
- Auth libraries (Auth.js, Better Auth, and others) protect their own endpoints — your app's other endpoints are still yours to protect.
When CSRF doesn't apply (and when it sneaks back)
If your API authenticates with a token the client adds explicitly — Authorization: Bearer … from memory or local storage — the browser won't attach it automatically, so classic CSRF doesn't apply. (That approach has XSS trade-offs instead. What Is a JWT?.)
CSRF sneaks back when:
- The API also accepts a session cookie as a fallback.
- CORS is misconfigured to allow credentials from arbitrary origins — then attackers can both send and read. (CORS Errors Explained.)
- Endpoints accept
text/plainor form encodings that browsers can send cross-site without a preflight. Requireapplication/jsonand reject other content types on JSON APIs.
Login CSRF and logout
- Login CSRF: an attacker logs the victim into the attacker's account, so the victim's later activity (saved cards, searches) lands there. Protect the login form too.
- Logout via GET lets any site log your users out. Use POST.
Checklist
- Session cookies set
SameSite=Lax(orStrict) explicitly, plusHttpOnlyandSecure - No state changes on GET
- CSRF tokens (session-bound) on all unsafe requests for cookie-authenticated endpoints
-
Sec-Fetch-Site/Originchecks reject cross-site unsafe requests - Framework protections enabled, not exempted
- JSON APIs require
application/json; CORS never reflects arbitrary origins with credentials - Login and logout protected
EasySpawn serves your app over HTTPS on your own domain, so Secure, SameSite session cookies and Origin checks behave in production exactly as they do in your tests. See how it works or join the waitlist.
Related: Content Security Policy · Authentication vs Authorization · What Is XSS?
Keep reading
Validating Input With Zod: One Schema for Forms, APIs, and Types
Every trust boundary — request bodies, query strings, webhooks, environment variables, AI output — needs runtime validation TypeScript can't provide. Using Zod schemas at each boundary, sharing them between client and server, stripping unknown keys, and useful errors.
Soft Deletes and Audit Logs: Keeping History Without Making a Mess
Deleting rows is irreversible; hiding them has costs too. When to use soft deletes, how to implement them without leaking 'deleted' data (partial indexes, unique constraints, views, RLS), the privacy tension with erasure requests, and how to build an audit log with triggers or application events.