Quick Answer

CSRF tricks a logged-in user's browser into sending a state-changing request to your site from an attacker's page. It works because browsers attach cookies based on the destination, not the origin of the request, so the attacker never needs to read the response. Defend with SameSite=Lax cookies plus a synchroniser CSRF token on anything that writes data. APIs that authenticate with an Authorization header are structurally less exposed, since that header is never attached automatically.

The attack, start to finish

Cross-site request forgery works because a browser sends your cookies to a site based on where the request is going, not on where it came from. The attacker never needs to read anything. They only need your browser to perform an action while it is logged in.

The setup is ordinary. You log into your college portal or a payments dashboard in one tab. The session cookie is now stored for that domain. In another tab you open a page somebody shared, and that page contains this:

<form action="https://bank.example/transfer" method="POST" id="f">
  <input type="hidden" name="to" value="attacker-account">
  <input type="hidden" name="amount" value="50000">
</form>
<script>document.getElementById('f').submit();</script>

The form submits itself the moment the page loads. Your browser sends the POST to the bank, and because the request is going to bank.example, it attaches the bank session cookie exactly as it would if you had clicked the button yourself. The bank sees an authenticated request with valid parameters and processes it.

The attacker page cannot read the response. Same-origin policy blocks that, and it does not matter: the transfer already happened. This is the part people get wrong when they first meet CSRF. They look for the data-theft step and conclude the attack is weak. There is no data-theft step. The value is the side effect.

It also does not need a form. An image tag pointing at a state-changing GET endpoint is enough, which is one reason GET /delete?id=5 is a bad idea even inside an admin panel. Anything that causes the browser to issue a request will do.

Why cookies are the enabling mechanism

The enabling property has a name: ambient authority. A cookie is attached automatically by the browser to every request to its domain, with no involvement from the page that triggered the request. Your application code never chooses to send it. That automatic attachment is what makes cookies convenient and what makes CSRF possible.

Two misconceptions are worth clearing up because both come up in interviews.

CORS does not stop this. CORS governs whether JavaScript on one origin may read a response from another. A plain HTML form submission is not JavaScript reading anything, so CORS never enters the picture. Forms can send application/x-www-form-urlencoded, multipart/form-data and text/plain cross-origin with no preflight request at all.

JSON-only APIs get partial protection by accident. A fetch with Content-Type: application/json is not a simple request, so the browser sends a preflight OPTIONS first and refuses to send the real request unless your server allows that origin. That is real, but it is a side effect rather than a control. If your server also parses form-encoded or text bodies for the same route, a plain form reaches it and the protection evaporates.

There is a matching consequence on the other side. If your API authenticates with a header your JavaScript sets explicitly, nothing is attached automatically, and the attacker page has no way to set it. That is the structural reason header-based auth is less exposed, covered further below.

SameSite cookies and what they miss

The SameSite cookie attribute tells the browser when it may attach a cookie to a cross-site request. It is the cheapest meaningful defence available.

  • SameSite=Strict means the cookie is never sent on any cross-site request, including a normal link. The side effect is real: a user clicking a link from an email to your dashboard arrives logged out, then refreshes and is logged in, which looks broken.
  • SameSite=Lax means the cookie is sent on top-level navigations that use a safe method, essentially clicking a link. It is not sent on cross-site POST submissions, iframe loads, or background fetches. That covers the classic auto-submitting form while keeping links usable.
  • SameSite=None means the old behaviour, and browsers require Secure with it. Use it only when you genuinely need cross-site cookies, such as an embedded widget.
Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax; Path=/

Chromium-based browsers treat a cookie with no SameSite attribute as Lax by default, and other browsers have handled the default differently over time. Set the attribute explicitly rather than relying on a default you cannot control.

Now the gaps, because "we set SameSite=Lax" is not a complete answer:

  • Same-site is not same-origin. If an attacker controls any subdomain of your registrable domain, requests from it are same-site, and the cookie goes along. Subdomain takeovers of forgotten staging hosts are a real path here.
  • Lax still allows top-level GET. Any endpoint that changes state on GET remains reachable. Keep GET read-only.
  • Freshly set cookies. Chrome has shipped a compatibility behaviour where a cookie created a couple of minutes ago is still attached to a cross-site top-level POST, so "Lax blocks cross-site POST" is not quite absolute. The details have moved between versions, which is one more reason not to lean on the attribute alone.
  • Older or unusual clients. A user on an outdated browser may not enforce it, and you do not control which browser someone uses.

Treat SameSite as a strong default that reduces the attack surface, and keep a token for anything that moves money, changes credentials or deletes data.

CSRF tokens done properly

The synchroniser token pattern is the standard defence. Your server generates a random value, stores it in the session, and embeds it in every form or sends it to the client for use in a header. On a state-changing request, the server checks that the value sent matches the value in the session. The attacker page cannot read your session or your HTML, so it cannot produce a matching value.

import crypto from 'crypto';

// Run this before rendering any page for a logged-in user
export function ensureCsrfToken(req, res, next) {
  if (!req.session.csrf) {
    req.session.csrf = crypto.randomBytes(32).toString('hex');
  }
  next();
}

function safeEqual(a, b) {
  if (typeof a !== 'string' || typeof b !== 'string') return false;
  const x = Buffer.from(a);
  const y = Buffer.from(b);
  if (x.length !== y.length) return false;
  return crypto.timingSafeEqual(x, y);
}

export function checkCsrf(req, res, next) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
  const sent = req.get('X-CSRF-Token') || (req.body && req.body._csrf);
  if (!safeEqual(sent, req.session.csrf)) {
    return res.status(403).json({ error: 'CSRF check failed' });
  }
  next();
}

Note that crypto.timingSafeEqual throws if the two buffers differ in length, so the length check has to come first. Note also that the token must come from a cryptographic random source; Math.random() is predictable and defeats the whole mechanism.

Common mistakes in real implementations:

  1. Checking the token only on POST while leaving PUT and DELETE unprotected.
  2. Generating a fresh token per request and getting confused when the user has two tabs open. Per-session is simpler and sufficient.
  3. Putting the token in a URL query string, where it leaks through referrers, browser history and server logs.
  4. Skipping the check on one convenient endpoint, usually the one an internal tool calls.

The double-submit variant sends the token both as a cookie and as a header, and compares the two without server-side storage. It is convenient for stateless services, but it assumes an attacker cannot set cookies on your domain, which fails if a subdomain is compromised. If you use it, sign the token so a value the attacker planted does not validate. A cheap extra layer for either approach is to reject state-changing requests whose Origin header is missing or does not match your site.

If your framework ships CSRF protection, use it. Django, Rails, Laravel and Spring Security all include a tested implementation, and the failure mode of hand-rolling this is silent.

Why an Authorization header API is less exposed

If your API authenticates through Authorization: Bearer <token>, the browser never attaches that header on its own. Your JavaScript sets it, and JavaScript on the attacker origin cannot read your token from another origin storage and cannot force the browser to add a header to a form submission. There is no ambient authority, so the classic CSRF shape simply does not apply.

await fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({ to, amount }),
});

This is a genuine structural advantage, and it is worth saying clearly in an interview: CSRF is a cookie problem, so removing cookies from the authentication path removes the problem. What it does not do is make the application safer overall, and there are four things that still bite.

  • XSS defeats it completely. A script on your origin reads the token from wherever you keep it and uses it directly. Header auth trades a CSRF risk for a larger XSS blast radius, which is the real argument behind the localStorage debate.
  • Mixed authentication. Many apps accept a bearer token and also accept a session cookie on the same routes, for the server-rendered pages. Any route that accepts the cookie needs CSRF protection, regardless of how the SPA talks to it.
  • CORS misconfiguration. Reflecting whatever Origin the request carries back into Access-Control-Allow-Origin while setting Access-Control-Allow-Credentials: true hands cross-origin JavaScript both send and read access. That is worse than CSRF because the attacker gets the response too.
  • State-changing GET. If a bearer token is not required for a route because it "only reads", but the route has a side effect, you have rebuilt the hole.

Practical guidance for a typical project: if you serve HTML pages with cookie sessions, use SameSite=Lax plus your framework CSRF tokens and keep GET read-only. If you have a separate API consumed by a single-page app or a mobile client, use header-based tokens, keep them out of long-lived readable storage where you can, and put your effort into XSS prevention instead. Mixing the two models in one codebase is where most real bugs live, because a route added later quietly inherits the wrong assumptions.

Frequently Asked Questions

Does CORS prevent CSRF? No, and this is one of the most common misunderstandings in web security interviews. CORS decides whether JavaScript on one origin may read a response from another. A CSRF attack does not read the response; it only needs the request to arrive and take effect. A plain HTML form can post cross-origin without any preflight, so CORS is never consulted for that path at all.
Is SameSite=Lax enough on its own? It blocks the classic auto-submitting cross-site form, which is most opportunistic CSRF. It does not help if an attacker controls a subdomain of your registrable domain, because such requests count as same-site. It also leaves any state-changing GET endpoint reachable through a top-level navigation, and you cannot guarantee every visitor's browser enforces it. Keep tokens on high-value actions.
Why does the CSRF token have to be random? The whole defence rests on the attacker being unable to guess or read the value. A token derived from the user ID, an email address or a timestamp can be reconstructed by anyone who knows those things, and Math.random is predictable enough to be treated as guessable. Generate it from a cryptographic source such as crypto.randomBytes or secrets.token_urlsafe, and compare it in constant time.
Do mobile apps need CSRF protection? Native apps that send a token in an Authorization header do not face the classic attack, because there is no shared cookie jar and no attacker page in the same browser context. The risk returns if your mobile app authenticates through cookies in a webview, or if the same backend routes also serve a cookie-authenticated website. Protect the route based on what the route accepts, not on which client you had in mind.
Should GET requests ever change data? No. Browsers, prefetchers, link previewers and crawlers all issue GET requests without user intent, so a state-changing GET can fire from a chat preview or an antivirus scanner. It also sits outside CSRF token checks in most frameworks and is still reachable under SameSite=Lax. Use POST, PUT, PATCH or DELETE for anything that writes, even for internal admin tools.