All posts
5 min read

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.

securityarchitectureintermediate

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=None for their own reasons.
  • "Site" isn't "origin." SameSite considers a.example.com and b.example.com the 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=None cookies (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 whose Origin isn't yours.
  • Sec-Fetch-Site — same-origin, same-site, cross-site, or none (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 Origin header 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/plain or form encodings that browsers can send cross-site without a preflight. Require application/json and 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 (or Strict) explicitly, plus HttpOnly and Secure
  • No state changes on GET
  • CSRF tokens (session-bound) on all unsafe requests for cookie-authenticated endpoints
  • Sec-Fetch-Site / Origin checks 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