CSRF — Cross-site request forgery

Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: Tricking a user’s browser into making a request to your site while they’re logged in — so the request goes through with their auth, but they didn’t intend it. Cookies are the reason this exists; SameSite is most of the defense.


In plain English

Cross-Site Request Forgery (CSRF) exploits a quirk of how browsers handle cookies: when you visit any site, your browser automatically sends along any cookies that belong to that site. So if you’re logged into your bank, and then a malicious site convinces your browser to make a request to your bank’s transfer endpoint, your bank cookies go along with the request automatically. From the bank’s perspective, the request looks legitimate — it has the right cookies — even though you never intended to make it.

Classic example: you log into your bank. You then visit evil.com in another tab. Evil.com has hidden code:

<img src="https://yourbank.com/transfer?to=attacker&amount=10000">

Your browser sees the <img>, makes a GET request to that URL, includes your bank cookies, and the bank processes the transfer. You never clicked anything; the attacker just needed you to visit their page while logged in.

The fix is twofold:

  1. Modern browsers default to SameSite=Lax for cookies — they don’t send cookies on most cross-site requests anymore.
  2. Apps add CSRF tokens — unique, per-session values that legitimate forms include and attackers can’t predict.

Like XSS, CSRF is one of the oldest web attacks. Modern frameworks (Next.js Server Actions especially) make it harder to be vulnerable. But it’s not gone — anywhere you have authenticated state-changing endpoints, you need to think about CSRF.


Why it matters

  • Affects every authenticated state-changing endpoint. Anywhere a logged-in user can cause something to happen with a request, CSRF is theoretically possible.
  • No code execution needed. Unlike XSS, CSRF doesn’t require running JavaScript on your site. Just luring your user to a page somewhere else.
  • High-impact attacks. Money transfers, account changes, posting content, account deletion.
  • Modern browsers help but aren’t a complete fix. Old browsers without SameSite, edge cases, configuration errors — still happen.

How CSRF works (in detail)

The exploit relies on three facts:

  1. Cookies attach automatically. When the browser makes a request to yourbank.com, it adds the bank’s cookies regardless of who initiated the request.
  2. HTML can initiate cross-site requests. <img src>, <form action>, <script src>, even <link href> all cause requests to arbitrary URLs.
  3. The server can’t tell where the request “came from.” A GET request from your tab vs. one triggered by an evil iframe looks identical to the server.

So attacker:

  • Crafts a page (or email, or ad) with hidden form/image targeting your endpoint
  • Convinces user to load the page while authenticated
  • User’s browser fires the request
  • Your server processes it as if user intended it

SameSite cookies — the modern default defense

Modern browsers introduced the SameSite cookie attribute that controls when cookies are sent cross-site:

ValueBehavior
StrictCookie only sent for same-site requests. Most secure; breaks some legitimate flows (e.g. coming from search engine results to a logged-in page).
LaxCookie sent on top-level navigation (clicking a link to your site) but NOT on cross-site sub-requests (images, forms loaded from another origin). Modern default.
NoneCookie sent on all cross-site requests. Requires Secure. Use only when needed.

Lax is the modern browser default if no SameSite is set. This single change has made CSRF much harder by default — most CSRF patterns rely on the cookie being attached to a cross-site request, which Lax blocks.

Set your auth cookies to SameSite=Lax explicitly. See Sessions & cookies.


CSRF tokens — the application-layer defense

Even with SameSite, defense in depth requires CSRF tokens. Pattern:

  1. Server generates a random token, ties it to the user’s session
  2. Server includes the token in legitimate forms (<input type="hidden" name="csrf_token" value="abc123...">) or as a custom HTTP header
  3. Server requires the token on every state-changing request
  4. Attacker can’t read the token (same-origin policy) so can’t include it in forged requests

CSRF tokens block CSRF even without SameSite. Belt + suspenders is the right approach.


How Next.js handles CSRF

Server Actions

When you use Next.js Server Actions:

async function transferMoney(formData: FormData) {
  "use server";
  // ...
}
 
<form action={transferMoney}>...</form>

Next.js automatically:

  • Issues a per-session CSRF token
  • Includes it in the form
  • Verifies it on submission

You don’t write CSRF code. Server Actions are CSRF-safe by default.

Route Handlers (custom API routes)

// app/api/transfer/route.ts
export async function POST(request: Request) {
  // ...
}

These are NOT automatically CSRF-protected. You either:

  • Verify SameSite cookies are in use (defense)
  • Add CSRF token verification yourself
  • Use a library like csrf or @nestjs/csrf

For most webapps, prefer Server Actions over custom API routes for state-changing operations.

fetch from your own client

Calling your own API from your React components passes the same-origin check by default and automatically includes cookies. Cross-site requests to your API from another origin require explicit CORS allowance, so they don’t happen accidentally.


A concrete example: a CSRF attack

A user logged into gallery.app. Attacker hosts:

<!-- evil.com/seems-legit.html -->
<h1>Cute Cats!</h1>
<form id="csrf" action="https://gallery.app/api/delete-account" method="POST">
  <input type="hidden" name="confirm" value="yes">
</form>
<script>document.getElementById('csrf').submit();</script>

When the logged-in user visits evil.com:

  1. Browser loads the page
  2. JavaScript auto-submits the form
  3. Browser POSTs to gallery.app/api/delete-account with the user’s session cookie attached
  4. gallery.app deletes the account

Defenses that would block this:

  • SameSite=Lax cookie → cookie not sent on cross-site POST → server can’t authenticate the request
  • CSRF token → form lacks the legitimate token → server rejects
  • Both, ideally

Modern stacks usually have both.


CSRF vs XSS — the distinction

AspectCSRFXSS
Attack vectorTricked browser sends requestAttacker JS runs in your page
Requires user toVisit attacker’s site while logged inVisit your page (with injected content)
DamageWhatever the request can doAnything JS can do
Server can detect?Look for missing token or wrong originOften invisible
MitigationSameSite, CSRF tokens, Origin/Referer checksOutput escaping, CSP, no dangerouslySetInnerHTML
Modern framework defaultOften handled (Server Actions)React escapes by default

Often confused; they’re different attacks with different fixes. Sometimes both work together — XSS to bypass CSRF tokens (the injected script can read tokens from the page).


Other CSRF defenses

Origin / Referer header checks

On state-changing endpoints, verify the Origin header (or Referer) matches your domain. Attackers’ cross-site requests will have a different origin.

Caveats:

  • Origin header isn’t always present (older browsers, some clients)
  • Use as one of several checks, not sole defense

A token is set in a cookie AND submitted as a form field. Server verifies they match. Works without server-side session state.

Custom request headers

Browsers can’t easily make cross-site requests with custom headers (CORS preflight blocks it). Requiring a custom header like X-Requested-With: XMLHttpRequest blocks many CSRF attempts.

Sensitive operations require re-authentication

For very sensitive ops (password change, money transfer): re-prompt for password regardless. Defense for when other layers fail.


CSRF in APIs (JWT-based auth)

If your API uses JWT in Authorization: Bearer headers rather than cookies, CSRF is much harder to exploit:

  • Tokens in headers aren’t sent automatically by the browser
  • Attacker would need XSS to read the token from localStorage / memory

This is one reason mobile apps using bearer tokens have lower CSRF risk. The trade-off: headers + localStorage = vulnerable to XSS instead.

There’s no free lunch in auth security. Pick the trade-offs deliberately.


Common gotchas

  • Trusting GET requests for state changes. Some servers process state changes via GET (e.g. /delete?id=123). Even with cookies SameSite=Lax, top-level GET requests still send cookies. NEVER do state-changing work in response to GET requests. Always POST/PUT/DELETE.

  • Forgetting CSRF protection on custom API routes. Server Actions are CSRF-safe; raw Route Handlers aren’t. Mixing them in one app means uneven protection.

  • SameSite=None without thinking. Sometimes needed (e.g. embedded widgets, OAuth). But it re-enables CSRF as a vector. Use sparingly.

  • CSRF tokens stored only in cookies. If both the cookie and the form field come from the same source (cookie), attacker can copy the cookie and forge requests. Tokens need to be unguessable AND tied to the session.

  • Tokens that don’t change per session. A long-lived token leaked once stays valid forever. Rotate.

  • Hardcoded CSRF tokens in tests. Sometimes leak into production. Use environment-aware token generation.

  • Origin header check too loose. Allowing wildcards or treating empty origin as same-origin defeats the check.

  • Verifying only on some endpoints. Attackers target the weakest endpoint. Apply CSRF protection uniformly.

  • CORS misconfiguration. Access-Control-Allow-Origin: * with Allow-Credentials: true is forbidden by spec but some setups try; security implications are bad.

  • CSRF via subdomain. If a.example.com is compromised (XSS), it can read cookies set for .example.com and forge CSRF. Scope cookies tightly.

  • Login CSRF. Less famous variant: attacker logs YOU into THEIR account, then can read what you do “as them.” Mitigation: same token mechanism.

  • CSRF in single-page apps via auth headers. If your SPA uses Authorization headers (not cookies), classic CSRF is moot. But XSS becomes worse — attacker JS can read tokens.

  • Long-lived tabs. User opened your bank yesterday, tab still open. Today they visit evil.com. Bank session might still be alive. Short sessions help.

  • Magic links and OAuth callbacks. These have their own attack vectors that resemble CSRF. Validate redirect URLs.

  • Re-using nonce/token across forms. Reduces protection. Per-form tokens are safer for high-value actions.

  • CORS preflight bypass. Some attackers craft requests that don’t trigger preflight (simple requests with only Content-Type: application/x-www-form-urlencoded and no custom headers). Don’t rely on CORS preflight alone.

  • Forgetting CSRF for password reset. Password reset is state-changing. Same rules apply.

  • Trusting Referer header alone. Privacy modes strip Referer. Some browsers omit it. Use Origin in addition.


See also

Sources