About Posts Series Contact
Security 12 min read

CSRF: How Browsers Betray You (and the Token That Stops It)

CSRF exploits the fact that browsers attach cookies to every cross-site request — automatically, invisibly, without asking. This post shows the attack in full, explains why CORS doesn't stop it, and builds the server-side defences that do.

Honey Sharma

A DevForum user visits a forum on a completely unrelated site. The page looks normal — a discussion thread, a few images. Hidden in the HTML is this:

<!-- attacker's page — evil.com — do not ship this -->
<form id="f" action="https://devforum.com/account/delete" method="POST">
  <input type="hidden" name="confirm" value="true" />
</form>
<script>document.getElementById('f').submit();</script>

The form submits the moment the page loads. The user’s browser sends a POST to devforum.com/account/delete. It attaches the DevForum session cookie automatically — the same cookie it attaches to every request to devforum.com. The server receives a valid session token, confirms the account deletion request, and returns 200.

The user never clicked anything on DevForum. The attacker’s site never received the response. The account is gone.

This is Cross-Site Request Forgery. The server did exactly what it was told — by the attacker.

Why the Browser Does This

Browsers were designed to attach cookies to requests unconditionally. When devforum.com set a session cookie, the browser committed to sending it with every future request to devforum.com — regardless of which page initiated the request.

This behaviour is fundamental to how the web works. Without it, clicking a link from a Google search result to a site where you’re already logged in would require you to log in again. Every navigation would lose session state.

CSRF is not a browser bug. It is the intentional default behaviour of cookies, used against you.

Why CORS Doesn’t Save You

The previous post covered how CORS blocks cross-origin response reads. Developers often assume that same protection extends to cross-site requests. It does not.

The browser still sends the request. The form POST to devforum.com/account/delete goes through. The server processes it. The state change happens. CORS only prevents evil.com’s JavaScript from reading the response — but the damage was done before the response arrived.

Why CSRF Still Works in 2026

Modern browsers defaulted SameSite to Lax starting in 2020, which blocks many CSRF vectors. (SameSite is a cookie attribute that controls when the browser includes cookies on cross-site requests — it’s covered fully in the next post. For now: Lax means cookies are sent on top-level navigations but not on cross-site form POSTs or fetch() calls.) But the attack is not solved. It survives in several real scenarios:

Subdomain attacks. If an attacker can run code on any subdomain of your domain, they can set cookies that scope to your apex domain — and those cookies are sent on same-site cross-origin requests that SameSite=Lax permits.

Top-level GET navigations. SameSite=Lax permits cookies on top-level navigations using safe methods. If a state-changing action can be triggered via a GET request (search, export, confirm via link), Lax does not protect it.

Legacy browsers. SameSite is not universally supported. Any user on an older browser — common in enterprise environments — receives no protection from browser-default SameSite behaviour.

Mobile WebViews. Some WebView implementations handle cookies differently from browser behaviour. SameSite semantics vary.

Explicitly SameSite=None cookies. Cookies set by legacy SSO systems, third-party integrations, or payment providers are often SameSite=None by requirement. They are fully vulnerable to CSRF.

The Attack in Full

CSRF Attack — How evil.com Deletes a DevForum Account
Serves page with hidden auto-submitting form POST /account/delete confirm=true Cookie: session=abc123 — auto-attached by browserValid session — process deletion server has no way to distinguish this from a legitimate request200 OK — account deleted evil.com Victim's Browser devforum.com
The browser attached the session cookie automatically. The server saw a valid session making a POST to a valid endpoint. Everything looked legitimate.

The server’s perspective: a POST arrived with a valid session cookie, the right Content-Type, and a confirm=true body parameter. It looked identical to a legitimate account deletion request. Without an additional check that the server itself issued, there is no way to tell the difference.

That additional check is the synchronizer token.

The Synchronizer Token Pattern

The synchronizer token pattern (also called the CSRF token pattern) adds a server-generated secret to every state-changing request. The server issues the token, the client includes it, and the server validates it before acting. An attacker on evil.com has no way to know the current token — they cannot read responses from devforum.com due to SOP — so they cannot include it in a forged request.

How it works

  1. On session creation (or per request, for stricter security), the server generates a cryptographically random token and stores it server-side, associated with the session.
  2. The token is embedded in every HTML form that performs a state-changing action.
  3. On every state-changing request (POST, PUT, PATCH, DELETE), the server reads the token from the request body or a custom header, and compares it to the stored value.
  4. If they match — the request is legitimate. If not — 403.
// Express — CSRF token middleware (simplified)
import crypto from 'crypto';

function generateCsrfToken(req) {
  const token = crypto.randomBytes(32).toString('hex');
  req.session.csrfToken = token;
  return token;
}

function validateCsrfToken(req, res, next) {
  const tokenFromRequest =
    req.body._csrf ||                     // form body
    req.headers['x-csrf-token'] ||        // fetch with custom header
    req.headers['x-xsrf-token'];          // Angular's default header name

  if (!tokenFromRequest || tokenFromRequest !== req.session.csrfToken) {
    return res.status(403).json({ error: 'CSRF token invalid or missing' });
  }

  next();
}

// Usage
app.post('/account/delete',
  validateCsrfToken,
  deleteAccountHandler
);

In a server-rendered HTML form:

<form action="/account/delete" method="POST">
  <input type="hidden" name="_csrf" value="<%= csrfToken %>" />
  <button type="submit">Delete account</button>
</form>

In a React/Vue app using fetch():

// Token delivered via a meta tag or an API endpoint
const token = document.querySelector('meta[name="csrf-token"]').content;

fetch('/api/account/delete', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': token,          // custom header — cannot be set by a cross-origin form
  },
  body: JSON.stringify({ confirm: true }),
  credentials: 'include',
});
Synchronizer Token — Legitimate Request Validated
GET /settings 200 OK — page with <input name='_csrf' value='t0k3n'> Session stores: csrfToken = 't0k3n'POST /account/email _csrf=t0k3n Cookie: session=abc123session.csrfToken === body._csrf — match token validates200 OK — email updated Victim's Browser devforum.com
The token was issued by the server, stored in the session, and included by the legitimate client. An attacker on evil.com cannot read the token from the GET /settings response — SOP blocks it.

The forged request from evil.com arrives without _csrf — the attacker never loaded the settings page from DevForum’s server. The validateCsrfToken middleware returns 403 before any business logic runs.

The synchronizer token pattern requires server-side session storage to hold the token. For stateless APIs that do not maintain session state server-side, there is an alternative: the double-submit cookie.

The idea: set the same random value in both a cookie and in each state-changing request (as a header or body field). The server checks that the two values match. It does not need to store the value itself — it just validates that cookie and request agree.

// Server — set the CSRF cookie on login
res.cookie('csrfToken', crypto.randomBytes(32).toString('hex'), {
  httpOnly: false,   // JavaScript must be able to read this cookie to include it in headers
  sameSite: 'strict',
  secure: true,
});

// Client — read the cookie and include in every state-changing request
function getCookie(name) {
  return document.cookie
    .split('; ')
    .find(row => row.startsWith(name + '='))
    ?.split('=')[1];
}

fetch('/api/account/delete', {
  method: 'POST',
  headers: {
    'X-CSRF-Token': getCookie('csrfToken'),  // must match the cookie value
  },
  credentials: 'include',
});

// Server — validate that header and cookie values match
function validateDoubleSubmit(req, res, next) {
  const fromHeader = req.headers['x-csrf-token'];
  const fromCookie = req.cookies.csrfToken;

  if (!fromHeader || !fromCookie || fromHeader !== fromCookie) {
    return res.status(403).json({ error: 'CSRF token mismatch' });
  }
  next();
}
Double-Submit Cookie — How the Same Value Proves Same Origin
Login response: Set-Cookie: csrfToken=abc (httpOnly: false) JS-readable cookieJS reads csrfToken cookie → adds X-CSRF-Token: abc header POST /account/settings Cookie: csrfToken=abc | X-CSRF-Token: abcHeader matches cookie — 200 OK values match → legitimatePOST /account/settings (forged) Cookie: csrfToken=abc (auto-sent) | X-CSRF-Token: ??? (cannot read cookie)Header missing or wrong — 403 Forbidden SOP blocks evil.com from reading the cookie DevForum Server Legitimate Browser evil.com
The attacker's browser sends the csrfToken cookie automatically — but evil.com's JavaScript cannot read it (SOP). Without the cookie's value, the attacker cannot forge a matching X-CSRF-Token header. The mismatch triggers a 403.

Choosing Between the Two Patterns

Synchronizer TokenDouble-Submit Cookie
Server state requiredYes — token stored in sessionNo — stateless
Subdomain-safe by defaultYesOnly with __Host- prefix on the cookie
Works with SPAsYes — token delivered via API or meta tagYes — token read from cookie by JS
Works without JavaScriptYes — token in hidden form fieldNo — JS must read cookie and set header
ComplexityModerateLow

For most server-rendered apps: synchronizer tokens. For stateless APIs where server-side session storage is not available: double-submit cookies — paired with SameSite=Strict and the __Host- prefix covered in the next post.


CSRF Defence Checklist
Protect every state-changing endpoint with a CSRF token

POST, PUT, PATCH, DELETE — all of them. GET endpoints should never perform state changes. If a state change requires a GET (e.g. email confirmation links), add a single-use signed token in the URL rather than relying on a session cookie alone.

Use per-request tokens for sensitive actions

A per-session CSRF token is adequate for most endpoints. For high-value actions — account deletion, email change, password reset — rotate the token per request. An intercepted token then has a one-request window of exposure.

Do not rely on CORS to prevent CSRF

CORS blocks response reads. The forged request still reaches the server. Every state-changing endpoint needs explicit CSRF token validation regardless of your CORS configuration.

Do not rely on Content-Type alone

Requiring Content-Type: application/json reduces but does not eliminate CSRF risk. Flash (before its deprecation) and some browser edge cases could send custom content types cross-origin. Token validation is not optional.

The next layer is SameSite + __Host- prefix + Fetch metadata

Tokens are the primary defence. Cookie attributes and Fetch metadata request headers are the secondary layer that handles the scenarios tokens miss. Both posts in this block are required for a complete defence-in-depth posture.

Honey Sharma

Software engineer focused on web engineering, TypeScript, and distributed systems.