← Back to Logs

How CSRF Protection Actually Works

Try the interactive lab for this articleTake the quiz (6 questions · ~5 min)

Cross-site request forgery only makes sense if you think like a browser. The browser stores a session cookie for bank.example, and when a request goes to bank.example, it attaches that cookie automatically if policy allows it. It does not ask whether the user meant to initiate the action. It does not know whether the request came from the bank's own page, from a hostile page in another tab, from an auto-submitted form, or from an image tag on an attacker-controlled site. It just applies the rules for cookies, navigation, and request dispatch.

That automatic attachment of authority is the whole bug class. CSRF is not an attacker stealing a token. It is an attacker spending a token the browser already has.

This is why the most useful way to think about CSRF is not "forms are dangerous" or "add a token field to POST requests". The useful model is: which cross-site requests can a browser send without user intent, which credentials will the browser attach to them, and what checks on the server side prove that the request came from the site's own interaction flow rather than from a hostile origin.

Once you think in those terms, the defences line up cleanly. SameSite changes when cookies ride along. CSRF tokens prove that the initiator had access to same-origin state the attacker origin cannot read. Origin and Referer checks validate where the request came from. Custom headers and JSON APIs can force the browser into a CORS preflight path, which changes what an attacker page can send at all. None of these layers is magic. Each one strips away a different form of ambient authority.

The Attack In Its Smallest Complete Form

Suppose a user in Milan is logged into their bank at https://bank.example. Their browser holds this cookie:

Set-Cookie: session=abc123...; Secure; HttpOnly; SameSite=None

Now they open a hostile page in another tab. That page contains an invisible form:

<form action="https://bank.example/transfers" method="POST" id="steal">
  <input type="hidden" name="iban" value="DE89370400440532013000">
  <input type="hidden" name="amount" value="5000">
  <input type="hidden" name="currency" value="EUR">
</form>
<script>
  document.getElementById('steal').submit()
</script>

If the bank's transfer endpoint accepts form-encoded POST requests and relies only on the presence of the session cookie for authentication, the browser does the rest.

  1. The hostile page creates the request.
  2. The destination origin is bank.example.
  3. Cookie policy says the session cookie may be attached.
  4. The browser sends the POST with the victim's authenticated session.
  5. The bank receives what looks like a valid authenticated action.

The attacker never needed to read the response. This point is worth emphasising because it causes endless confusion. Same-origin policy mostly governs whether attacker-controlled JavaScript can read a cross-origin response. CSRF does not require reading it. The attacker only needs the browser to send the request with the victim's credentials.

That distinction is why teams sometimes break a proof of concept by checking the browser console and seeing a CORS error, then conclude the site is safe. The CORS error often appears after the dangerous part already happened. The response was blocked from being read. The state-changing request may still have landed.

A Forged Request At Header Level

It helps to stop thinking in abstract terms and look at the wire image of the same attack.

Suppose the hostile page auto-submits the transfer form while the victim still has a cross-site-capable session cookie. The bank might receive something like this:

POST /transfers HTTP/1.1
Host: bank.example
Cookie: session=abc123...
Content-Type: application/x-www-form-urlencoded
Origin: https://evil.example
Referer: https://evil.example/promo.html
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
 
iban=DE89370400440532013000&amount=5000&currency=EUR

Notice what is and is not surprising here.

The Cookie header is present because the browser believes policy allows it. The attacker never saw the cookie value. The browser attached it on the user's behalf.

The Origin and Referer headers are already incriminating if the server bothers to look. They say the request came from another site. A transfer endpoint that ignores both is choosing not to use information the browser handed it for free.

The Sec-Fetch-* headers are newer but just as useful. They are part of the Fetch Metadata family. Sec-Fetch-Site: cross-site tells you this navigation began on another site. Sec-Fetch-Mode: navigate tells you it is a document navigation rather than, say, a same-origin XHR from your own UI. These headers are not a complete CSRF defence on their own, but they are an excellent filter for obviously suspicious traffic.

Now change just one detail and the request disappears. If the session cookie had been SameSite=Lax, this cross-site form POST would normally arrive without the session cookie at all. The server would still receive a request, but not an authenticated one. If the bank also required a synchroniser token, the body would still be missing the token even if some cookie slipped through. If the server validated Origin, it would fail before business logic ran. Three layers, three independent ways for the attack to die.

This is why CSRF review should include literal captured requests. The request headers tell you which browser features are already trying to help and which ones your application is ignoring.

One more subtle point follows from this header view. Because the forged request often looks like an ordinary browser navigation, generic application logs may not mark it as suspicious. It can have a perfectly plausible user agent, IP address, TLS fingerprint, and session id, because all of those belong to the real user. The only thing that distinguishes it is where the navigation started and whether the request included data the attacker origin could not know. That is exactly what token checks, origin validation, and fetch metadata are meant to surface.

Which Requests Are Actually Dangerous

Browsers can send more cross-site traffic than most developers realise. The list matters because CSRF protection only needs to cover the request shapes the attacker can induce.

Top-level navigations

A hostile site can send the user to another site by linking, redirecting, or auto-submitting a form. Historically this included GET navigations and form POST navigations without much friction. Modern cookie policy changes have reduced what credentials ride along, but navigations still matter because they are the path most likely to include SameSite=Lax cookies.

Form submissions

Classic HTML forms can send application/x-www-form-urlencoded, multipart/form-data, and text/plain bodies across origins without JavaScript sophistication. That is why legacy applications with form-backed state changes are the canonical CSRF targets.

Subresource requests

Image tags, script tags, link prefetches, and other elements can cause requests to fire automatically. These are most relevant when the target application incorrectly puts state-changing actions on GET endpoints.

<img src="https://bank.example/delete-recipient?id=42">

If that endpoint mutates state on GET and cookies are attached, the browser has just performed an action on page load.

Programmatic requests

Cross-origin fetch and XMLHttpRequest from attacker pages complicate the picture. They can often trigger requests, but the browser will enforce CORS rules for reading the response and may perform a preflight first if the request is non-simple. That preflight behaviour is useful defensively because it creates a place where the server has to opt in explicitly before the dangerous request is even sent.

The core rule is simple: CSRF lives in the intersection of three sets.

  • requests the attacker origin can cause the browser to make
  • requests on which the browser attaches the victim's credentials
  • requests the server accepts as state-changing actions

Take any one of those away and the attack dies.

The Browser's Cookie Decision Is The First Line Of Defence

For years, browsers attached cookies to cross-site requests almost unconditionally. That is why CSRF was such a routine bug class in server-rendered apps. The modern SameSite attribute changed that landscape significantly.

There are three modes that matter.

SameSite=None

The cookie is sent on same-site and cross-site requests, provided it is also marked Secure. This is necessary for some cross-site use cases such as embedded third-party widgets, federated login flows, and certain payment redirects. It is also the least forgiving setting from a CSRF perspective.

SameSite=Lax

The cookie is sent on same-site requests and on top-level cross-site navigations that use safe methods such as GET. It is not sent on typical cross-site subrequests such as form POSTs, iframe loads for state-changing actions, or background fetch requests from another origin.

For most ordinary web applications, Lax kills the classic hostile-form POST attack outright while preserving common login and navigation behaviour.

SameSite=Strict

The cookie is sent only in same-site contexts. If the user clicks a link from another site into your app, the cookie stays behind until they are back in a same-site flow. This is the strongest posture but can be unfriendly for applications that expect cross-site entry into authenticated pages.

This one change explains why some old CSRF proof-of-concept pages stopped working over the last few years. The browser stopped carrying the victim's session automatically on many cross-site request types.

But SameSite is not the whole story.

First, if you still have a dangerous GET endpoint, Lax may still attach cookies during a top-level navigation. A transfer endpoint that accepts query-string parameters on GET is a design bug even if the team feels modern because it set SameSite=Lax.

Second, not every session architecture can use Strict or even Lax everywhere. OAuth login returns, third-party identity providers, payment processor callbacks, and embedded enterprise products sometimes need explicit exceptions. The dangerous part is when those exceptions grow until the effective policy is None again.

Third, SameSite is a browser-side filter on credential attachment. It does not prove that the request came from your own UI flow. It only removes some unauthorised ways the browser would otherwise spend the cookie.

The right reading of SameSite is: excellent baseline, not a reason to stop layering.

Same-Site Is Not The Same Thing As Same-Origin

One of the easiest ways to misunderstand modern CSRF behaviour is to treat "site" and "origin" as interchangeable. They are not.

An origin is scheme, host, and port together. https://app.example.com and https://admin.example.com are different origins. A site, in the cookie sense, is broader. Modern browsers use a schemeful site model built roughly around the registrable domain plus scheme. That means https://app.example.com and https://admin.example.com are different origins but usually the same site, while http://app.example.com and https://app.example.com are different sites because the scheme changed.

Why does that matter? Because SameSite is deciding whether a cookie may be sent in a same-site context, not whether the JavaScript caller is same-origin with the target resource.

This leads to two practical consequences.

First, sibling subdomains inside one organisation can often send each other cookies in ways developers do not expect. If your support tool at support.example.com and your main product at app.example.com share session scope broadly, a bug or takeover on one sibling can become a CSRF-relevant starting point against the other even though the origins are different. The browser may still consider the request same-site.

Second, CORS and SameSite answer different questions. CORS asks whether JavaScript on one origin may read a response from another origin. SameSite asks whether the browser may attach a cookie based on the site relationship of the request. You can have a request that is blocked by CORS for reading but still carries cookies, and you can have a request between sibling origins that is same-site for cookies but still cross-origin for JavaScript access.

That combination surprises teams building split products. A setup like this is common:

  • https://app.example.com for the user frontend
  • https://admin.example.com for operations staff
  • https://api.example.com for JSON APIs

From the browser's point of view, those are three origins. From the SameSite point of view, they may all still sit inside one site. If you issue one broad cookie that reaches all of them, you have created a larger authority surface than the origin diagram alone suggests.

This is also why subdomain governance matters for CSRF and not only for DNS hygiene. If a forgotten microsite, abandoned marketing host, or stale SaaS CNAME under the same registrable domain can be taken over, the attacker may gain a same-site foothold with surprising leverage over shared cookies and request flows.

The safe habit is to design session scope deliberately.

  • Scope cookies as narrowly as product architecture allows.
  • Avoid sharing one powerful session across unrelated subdomains without a clear reason.
  • Treat every new sibling origin as a potential place from which browser authority can be spent.

Once you see the site-origin distinction clearly, several confusing behaviours stop being confusing. SameSite=Lax is not promising "only my exact frontend can use this cookie". It is promising something narrower and more mechanical: the browser will limit when this cookie travels based on site relationship and request type. That is useful, but it is not an application-level trust model by itself.

CSRF Tokens Prove The Request Came From The Right Place

A CSRF token is a secret or unpredictable value that the legitimate site can place into the page or API flow, but an attacker origin cannot read. When the request comes back, the server checks for the token and rejects the request if it is missing or wrong.

The oldest pattern is the synchroniser token.

  1. The server creates a random token bound to the user session.
  2. The application embeds that token in every state-changing form.
  3. The browser submits the form with both the session cookie and the token.
  4. The server compares the submitted token with the one stored for that session.

A simplified implementation looks like this:

// On session creation
session.csrfToken = crypto.randomUUID()
 
// On form render
<input type="hidden" name="csrf" value={session.csrfToken} />
 
// On POST
if (req.body.csrf !== session.csrfToken) {
  return res.status(403).send('csrf check failed')
}

Why does this work? Because the hostile site can cause the browser to send the victim's session cookie, but it cannot read the bank's page to extract the token first. Same-origin policy blocks that read.

The double-submit cookie pattern is the stateless cousin.

  1. The server sets a CSRF cookie with a random value.
  2. Client-side code reads that cookie and sends the same value in a header or form field.
  3. The server checks that both copies match.

The attacker origin may cause the browser to send the cookie copy automatically, but it cannot read the cookie value and echo it back in the header or form field if the cookie is scoped and protected correctly. This pattern is common in single-page apps that want to avoid storing server-side token state.

A real example often looks like this:

Set-Cookie: csrf=7f8c...; Secure; SameSite=Lax; Path=/

and then, from same-origin JavaScript:

await fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-csrf-token': readCookie('csrf'),
  },
  body: JSON.stringify(payload),
})

The server checks that the header equals the cookie and rejects the request otherwise.

There are three details that separate a serious token design from a decorative one.

First, the token must be unpredictable. Obvious values such as user ids, timestamps, or unhashed email addresses are not tokens.

Second, the token must be bound to something meaningful, usually the user session. A global application-wide constant is not CSRF protection.

Third, the token must be required on every state-changing path, not just the one controller somebody remembered during a framework migration.

Per-request tokens versus per-session tokens are an operational tradeoff. Per-request tokens shrink replay windows but create more state churn and edge cases with the back button or multiple tabs. Per-session tokens are easier to deploy and are sufficient for most applications when combined with the other layers in this article.

Origin And Referer Checks Validate The Initiator

A CSRF token proves the requester had access to same-origin state. Origin and Referer checks address the question from the opposite direction: where did this request come from?

For many unsafe HTTP methods, modern browsers send an Origin header automatically. A legitimate same-origin request to the bank might contain:

Origin: https://bank.example

A hostile request from another site might contain:

Origin: https://evil.example

A server can reject anything except the expected origin.

const origin = req.headers.origin
if (origin !== 'https://bank.example') {
  return res.status(403).send('bad origin')
}

This is simple and powerful, especially for JSON APIs where your own frontend always runs on one or a small set of known origins.

Referer checks are the fallback when Origin is absent, especially on some navigation paths. They are a bit messier because privacy tooling, corporate proxies, and browser policy can strip or truncate them, but they are still useful in practice if you compare carefully and fail in a measured way.

There are two recurring mistakes here.

The first is substring matching. A check like if (referer.includes('bank.example')) is wrong because https://bank.example.evil.org/ also includes that string. Parse the URL and compare the actual origin.

The second is trusting proxy headers or mangled host reconstruction without understanding your deployment path. If the application sits behind a reverse proxy, the code that validates origin needs to know which headers are trustworthy and which ones a client can spoof. Otherwise a neat security check at the app layer turns into a string comparison against attacker-supplied input.

Origin checks are excellent because they are cheap and stateless. They are not a complete replacement for tokens because there are real environments where headers are missing or legitimate flows are more complicated. As with SameSite, the right mindset is layering.

Fetch Metadata Gives The Server More Context

Modern browsers send another set of headers that is particularly useful for CSRF defence: Fetch Metadata. The most important are Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest, and sometimes Sec-Fetch-User.

For a same-origin API call from your own application, you might see:

Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty

For a hostile cross-site form navigation, you might see:

Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
Sec-Fetch-User: ?1

That gives the server a fast way to say something like: "if this is a cross-site navigation to a state-changing endpoint, reject it before we even look at business parameters".

const site = req.headers['sec-fetch-site']
const mode = req.headers['sec-fetch-mode']
 
if (site === 'cross-site' && mode === 'navigate') {
  return res.status(403).send('cross-site navigation blocked')
}

This pattern is especially helpful for older applications where full token coverage is still being rolled out. It catches obvious hostile traffic early, and it produces clean logs that tell you which requests are arriving from suspicious contexts.

There are limits, so use it as a supporting layer, not a solitary defence. Not every client sends these headers. Some non-browser clients and older embedded browsers omit them. You also need to think about legitimate cross-site flows such as identity-provider returns or payment redirects, because those may intentionally arrive as cross-site navigations. The usual answer is to allow a small list of known callback routes and reject the pattern everywhere else.

Used that way, Fetch Metadata is one of the cheapest hardening steps available. It lets the server benefit from the browser's own knowledge about how the request was initiated, which is exactly the knowledge CSRF exploits are trying to blur.

Why JSON APIs Often Get An Extra Boundary For Free

Traditional CSRF attacks love simple form posts because browsers can send them cross-site with very little ceremony. JSON APIs behave differently if they are designed well.

Suppose your frontend sends this request:

POST /api/transfers
Content-Type: application/json
X-CSRF-Token: 7f8c...

From the browser's perspective, that is not a simple cross-origin request anymore. application/json and a custom header such as X-CSRF-Token trigger a CORS preflight. Before the real POST is sent, the browser asks the server whether the requesting origin is allowed.

OPTIONS /api/transfers
Origin: https://evil.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,x-csrf-token

If your server does not explicitly allow https://evil.example, the browser never sends the authenticated POST at all.

This is one reason modern SPA backends often require a custom header on state-changing requests even when they already have CSRF tokens. The custom header forces the request out of the easy HTML form path and into a stricter browser code path.

But this only helps if the CORS policy is tight. If the API says this:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

that configuration is invalid and browsers reject it. Teams notice the breakage, then often "fix" it by echoing arbitrary origins or over-broad allow-lists. If the attacker origin ends up allowed and credentials are allowed too, you have just rebuilt the dangerous cross-site path you were hoping to avoid.

The safe pattern is explicit allow-listing of your own frontend origins and nothing else.

const allowedOrigins = new Set([
  'https://app.bank.example',
  'https://admin.bank.example',
])

Then refuse every other origin at the preflight stage.

This is not the same as saying "CORS solves CSRF". It does not. It helps only for request shapes that are forced through preflight. Plain HTML forms do not go through CORS preflight, which is why token and cookie policy layers still matter.

Framework Defaults And Deployment Mistakes Matter More Than Theory

Most production CSRF bugs are not caused by a team that never heard of tokens. They are caused by a team that thought the framework had already handled it everywhere, while product growth quietly created exceptions.

A Rails app may have protect_from_forgery on controller-rendered forms, but a newer JSON endpoint mounted beside it may skip the middleware path entirely. A Django application may have CSRF middleware enabled, but a few webhook or admin endpoints were marked exempt during a crunch and never revisited. A Next.js frontend backed by Express may rely on SameSite=Lax cookies and assume that means the API is safe, while one legacy GET endpoint still changes account settings because it was built before the API redesign.

The danger is not ignorance. It is partial coverage combined with false confidence.

A useful review question is therefore not "does this framework support CSRF protection?" Every serious framework does. The useful question is "which exact routes, rendering paths, and clients are outside the happy path the framework protects by default?"

Here are some places where the answer is often uncomfortable.

Separate frontend and backend origins

Once the browser app lives at app.example and the API lives at api.example, teams often switch from server-rendered forms to fetch with cookies. That means they now depend on CORS, SameSite, token headers, and proxy configuration together. One misconfigured Access-Control-Allow-Origin response can re-open a route the monolith previously defended correctly.

Embedded partner portals

An enterprise product that used to be top-level may later need to appear inside an iframe for a partner in Amsterdam or Frankfurt. Suddenly someone wants SameSite=None so the session works in the embedded context. If the security review stops at "the iframe loads now", the product may have traded away one of its strongest baseline CSRF protections for the entire cookie.

SPA convenience helpers

Client wrappers around fetch often add the CSRF header automatically. That is good until one engineer bypasses the wrapper for a file-upload route or a micro-frontend uses a different HTTP client. The system has the appearance of central protection while one code path has quietly stepped outside it.

Reverse proxies and TLS terminators

Applications sometimes compute their own origin expectations from Host, X-Forwarded-Host, or X-Forwarded-Proto. If those trust boundaries are not configured correctly, the origin-validation code may compare attacker-controlled strings and pronounce the request safe. The application code can be logically correct and still fail because the deployment model lied to it about what the public origin is.

Session sharing across products

Companies love single sign-on across app.example, billing.example, support.example, and admin.example. Security engineers do not love it for free, because every shared session cookie broadens the set of surfaces from which a browser can spend authority. A weakly defended support tool can become the easiest path into a more sensitive billing action if the same session reaches both.

These are not reasons to avoid modern architecture. They are reasons to treat CSRF as a system property, not a controller-level checkbox.

The easiest way to tell whether you are relying on theory instead of deployment reality is to stage the hostile request yourself. Open a local HTML page, submit the cross-site form, observe the cookies and headers in a proxy, then compare that with what the team believed would happen. Production security work gets better the moment that experiment becomes normal.

The Edge Cases That Break Simplified Advice

A lot of CSRF guidance becomes wrong when reduced to one-line slogans. The edge cases are where teams get caught.

GET Endpoints That Mutate State

If a GET request can delete a record, transfer money, log the user out, change language settings, or send an email, you have created a CSRF surface that is much easier to hit because browsers and crawlers love GET. This is not just a security bug. It is a semantic bug against HTTP itself.

Login CSRF

Not every CSRF attack steals money. Sometimes the attacker forces the victim to log into the attacker's own account on the target site. The victim later enters personal data, shipping addresses, or payment details into what they think is their own session, and that data lands in the attacker's account. This is why login forms also need CSRF protection in cookie-backed applications.

OAuth state And Related Flows

OAuth and OpenID Connect carry their own CSRF-shaped problem during the redirect flow. The state parameter exists precisely to bind the returning authorisation response to the initiating browser session. Forgetting or weakening it recreates a login-forgery problem in a different costume.

Subdomain Takeover And Cookie Scope

If your session or CSRF cookies are scoped too broadly, such as .example.com, and an abandoned subdomain can be taken over by an attacker, the attacker may be able to set or influence cookies for the parent scope. That can undermine double-submit token designs and other assumptions about which origins are trusted members of the same site.

Cached Pages With Stale Tokens

Reverse proxies and CDNs can accidentally cache pages that contain per-session CSRF tokens. The next user receives a token that belongs to someone else or to an expired session, and the team sees random 403s. The usual bad response is to loosen token checking. The correct response is to fix cache headers and separate personalised responses from shared ones.

Compression Side Channels

Per-request tokens were historically recommended partly because of attacks such as BREACH, where secrets reflected into compressed responses could leak over many measured requests. The practical lesson is not that tokens are bad. The lesson is that reflected secrets, compression, and attacker-controlled input can combine in surprising ways, so high-value actions deserve conservative design.

Mobile And Hybrid Clients

Native mobile apps using bearer tokens in Authorization headers rather than browser cookies are usually not exposed to classical CSRF because the browser is not auto-attaching the credential cross-site. But hybrid apps that embed webviews with cookie-backed sessions can absolutely reintroduce the browser threat model. Ask what attaches the credential automatically. That is the deciding factor.

The reason to study these edge cases is not to memorise trivia. It is to avoid defending the cartoon version of CSRF while the real system exposes a different path.

A Practical Defence Stack For A Cookie-Backed Web App

If you run a conventional web application with authenticated browser sessions, a strong default posture in 2026 usually looks like this.

1. Make state changes use unsafe methods only when they truly change state

No meaningful action on GET. That closes off the easiest browser primitives immediately.

2. Set the session cookie with Secure, HttpOnly, and SameSite=Lax or Strict

For most applications, Lax is the baseline. Use Strict where product flow allows it. Document every cookie that needs None, because those exceptions deserve scrutiny.

3. Require a CSRF token on every state-changing route

Use synchroniser tokens for classic server-rendered apps and double-submit cookie or framework-native token middleware for SPAs. Whatever the framework provides, verify that every mutating route actually uses it.

4. Check Origin, and fall back to careful Referer validation when necessary

This is cheap defence-in-depth and catches classes of failures that token-only designs miss.

5. Prefer JSON APIs with custom headers for mutating operations

This pushes many dangerous requests into the preflight path and makes cross-site HTML form attacks impossible for those endpoints.

6. Keep CORS explicit and small

Allow only your real frontend origins. Send Vary: Origin if the response depends on origin. Never grow the allow-list just to make a test widget work without understanding the effect.

7. Log and alert on CSRF failures sensibly

A single failure may be a stale tab or a browser oddity. A burst of them against one sensitive route can indicate active probing. Visibility matters.

That stack is not glamorous, but it survives contact with production systems better than clever one-offs do.

How To Review A Real Application For CSRF Risk

A proper CSRF review starts from the wire and works backward.

First, inventory every state-changing route: transfers, profile edits, email changes, password resets, admin actions, API mutations, login, logout, account linking, device registration, webhook test buttons, and anything else that spends authority.

For each route, answer four questions.

  1. Which HTTP method does it use?
  2. Which credentials authenticate it: session cookie, bearer token, client certificate, something else?
  3. Can an attacker page cause a browser to send a request in that shape?
  4. Which exact checks prove the request came from the site's own flow?

Then test the answers against reality.

  • Inspect the actual Set-Cookie headers in a browser or proxy. Do not trust framework defaults from memory.
  • Look at the real HTML forms and SPA network calls. Is the token actually present everywhere?
  • Trigger a mutating request from a hostile local page and observe whether cookies ride along.
  • Inspect the server's CORS responses for preflights and actual requests.
  • Verify origin validation against the deployed hostnames, reverse proxies, and tenant domains.

A good review also checks for inconsistencies introduced by product growth. The legacy Rails admin may use synchroniser tokens correctly while the newer Next.js frontend talks to an API route that forgot to add token middleware. The public site may use SameSite=Lax while the embedded partner portal silently downgraded a cookie to None and reused the same session backend.

The principle is the same as in good XSS review: map the trust boundary and look for the places where convenience punched holes through it.

CSRF Protection Is Really About Removing Ambient Authority

The browser is extremely good at making the web usable. It remembers cookies, follows redirects, submits forms, loads resources, retries connections, and generally moves the user's session around without making them think about transport details. CSRF protection exists because all of that convenience can be turned against the user if the server does not distinguish between "the browser can send this request" and "the user intended this action".

Every effective defence strips one piece of that accidental authority away.

SameSite says the cookie does not always get to travel.

CSRF tokens say the requester must prove access to same-origin state first.

Origin checks say the request must come from the right place.

Custom headers and JSON APIs say the browser must go through a preflight gate before it can even try.

Safe HTTP semantics say destructive actions do not hide behind the easiest cross-site request shapes.

That is the whole model. Once you understand it, CSRF stops feeling like a checkbox about hidden form fields and starts looking like a design question about who is allowed to spend a browser session, under exactly which conditions. The companion lab walks that decision path step by step. The companion quiz checks whether you can predict when the browser will attach cookies and when the server should refuse the request. If you can do those two things reliably, you understand CSRF well enough to stop shipping it.