Earlier this year I reviewed a Node admin panel whose team told me, confidently, that CSRF was "solved by SameSite". They were half right. Their session cookie was SameSite=Lax, which blocks most cross-site form posts. But their "export users" action was a GET route, their marketing site on a sibling subdomain could make same-site requests, and one internal tool set its own cookie with no SameSite attribute at all, which Firefox and Safari do not default to Lax. None of that was exotic. It was three ordinary gaps that SameSite alone could not close.

I wrote this with help from AI tools and checked every snippet and config myself against current OWASP CSRF Prevention guidance, Express 5 and Laravel 11. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices, and I build the free developer tools at PixelValut. Everything here is about protecting state-changing requests in an app you own.

📚 Series Navigation — Passkeys & Session Hardening for Web Apps

Related earlier posts: Browser Secrets That Leak: Token Storage, Quotas & Outbound Controls · OAuth 2.1, OIDC ID Tokens & PKCE Forensics

How do you prevent CSRF in a modern web app?

Direct answer: Never change state on GET, set session cookies with SameSite=Lax or Strict plus Secure and HttpOnly, reject cross-site state-changing requests using the Sec-Fetch-Site header, fall back to an Origin allowlist when that header is missing, and keep synchronizer or double-submit tokens for forms and older clients. Layer them; each closes a gap the others leave.

An old brass mail slot in a wooden door with an envelope halfway through, AI-generated

Why CSRF still exists

CSRF works because the browser attaches your cookies to a request even when another site triggered it. The defenses all answer one question: did this state-changing request really come from my own pages? They answer it at different layers:

Defense What it checks Strength Gap it leaves
SameSite=Lax cookie Browser withholds cookie on cross-site subrequests and POSTs Strong default, no code Same-site subdomains, top-level GET, cookies without the attribute
SameSite=Strict Withholds cookie on all cross-site navigation Strongest cookie control Users arriving from links appear logged out
Fetch Metadata (Sec-Fetch-Site) Browser states the request's origin relationship Hard for a page to forge, simple to enforce Missing on very old browsers and non-browser clients
Origin header allowlist Exact origin of the initiating page Precise, includes subdomain detail Sometimes absent or null
Synchronizer / double-submit token Secret only your pages know Works everywhere Needs plumbing in every form and fetch

The important detail is site vs origin. app.example.com and blog.example.com are different origins but the same site. SameSite cookies treat them as friends. If any sibling subdomain can be taken over or hosts user content, it can send same-site requests with your cookies attached. That is why I don't stop at SameSite.

Layer 1: Cookies and routes

Start with what costs nothing:

// Express 5 + express-session app.use(session({ name: '__Host-sid', // __Host- prefix: Secure, path=/, no Domain attribute secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: true, sameSite: 'lax', path: '/' }, })); 

The __Host- prefix is underrated. The browser refuses to set a __Host- cookie with a Domain attribute, so a sibling subdomain cannot overwrite or plant your session cookie.

Then audit routes. Every route that writes data must use POST, PUT, PATCH or DELETE. Search your router for GET handlers that call save, delete, update, send or export, and move them.

Layer 2: Fetch Metadata

Modern browsers send Sec-Fetch-Site on every request, with one of four values: same-origin, same-site, cross-site or none (user typed the URL or used a bookmark). Pages cannot set this header themselves, so it is a reliable signal. This middleware is the core of my CSRF defense today:

const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); const ALLOWED_ORIGINS = new Set(['https://app.example.com']); export function csrfGuard(req, res, next) { if (SAFE_METHODS.has(req.method)) return next(); const site = req.get('Sec-Fetch-Site'); if (site) { if (site === 'same-origin' || site === 'none') return next(); return reject(res, req, `sec-fetch-site=${site}`); } // Fallback for clients that do not send Fetch Metadata const origin = req.get('Origin'); if (origin && ALLOWED_ORIGINS.has(origin)) return next(); if (!origin && req.get('Authorization')) return next(); // API clients using bearer tokens, not cookies return reject(res, req, `origin=${origin ?? 'missing'}`); } function reject(res, req, reason) { req.log?.warn({ reason, path: req.path }, 'csrf_rejected'); return res.status(403).json({ error: 'cross_site_request_blocked' }); } 

Notes from rolling this out:

  • I reject same-site by default. If a sibling subdomain genuinely needs to post to you, add its exact origin to the allowlist instead of trusting the whole site.
  • The Authorization branch is only safe because bearer tokens are not attached automatically by the browser. Never apply it to cookie-authenticated routes.
  • Log rejections for a week in report-only mode before enforcing. Real integrations (payment return pages, SSO callbacks) show up quickly. Those callbacks usually need their own verification, such as a state parameter or a signature, rather than a blanket exception.

Go added the same idea to its standard library as http.CrossOriginProtection in Go 1.25, which is a good sign this approach has become mainstream.

Layer 3: Tokens where they still matter

Fetch Metadata does not replace tokens everywhere. I keep synchronizer tokens for server-rendered forms and for any app that must support older clients. In Laravel this is already on: the ValidateCsrfToken middleware (named VerifyCsrfToken before Laravel 11) checks the _token field or the X-CSRF-TOKEN / X-XSRF-TOKEN header. The common mistake is the exceptions list:

// bootstrap/app.php (Laravel 11) ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ 'webhooks/stripe', // OK: verified by Stripe signature instead // 'api/*', // dangerous if any api/* route uses the session cookie ]); }) 

Every entry in except should have a comment explaining what verifies that route instead. If you cannot write that comment, the exception should not exist.

Layer 4: Verify it with real requests

Configs lie less often than people do, but they still lie. I test from the outside. Check what your login and state-changing endpoints actually return with the HTTP Status Checker, and break down redirect and callback URLs with the URL Parser to see exactly which origin and parameters an allowlist has to handle. Then against your own staging environment:

# Should be 403: simulated cross-site POST curl -i -X POST https://staging.example.com/account/email \ -H 'Sec-Fetch-Site: cross-site' -H 'Origin: https://other.test' \ -b '__Host-sid=TEST_SESSION' -d 'email=test@example.com' # Should succeed: same-origin POST with a valid token curl -i -X POST https://staging.example.com/account/email \ -H 'Sec-Fetch-Site: same-origin' -H 'Origin: https://staging.example.com' \ -H "X-CSRF-TOKEN: $TOKEN" -b '__Host-sid=TEST_SESSION' -d 'email=test@example.com' # Should be 403: no Fetch Metadata, unknown Origin curl -i -X POST https://staging.example.com/account/email \ -H 'Origin: https://other.test' -b '__Host-sid=TEST_SESSION' 

Put these in CI as integration tests, so a future refactor cannot quietly drop the middleware.

Which layers do you need?

App type Minimum I ship
SPA + JSON API on same origin, cookie session SameSite=Lax, __Host- cookie, Fetch Metadata guard, no state on GET
Server-rendered forms (Laravel, Rails, Django) Framework CSRF tokens + SameSite=Lax + Fetch Metadata guard
API with bearer tokens only No cookie auth, so CSRF does not apply; keep CORS strict
Multi-subdomain product All of the above + exact Origin allowlist, reject same-site by default

Diagnostic checklist

  • [ ] No GET route changes state (search handlers for writes).
  • [ ] Session cookie uses __Host- prefix, Secure, HttpOnly, SameSite=Lax or Strict.
  • [ ] Every cookie your app sets has an explicit SameSite value.
  • [ ] Unsafe methods reject Sec-Fetch-Site: cross-site (and same-site unless allowlisted).
  • [ ] Origin allowlist fallback exists for clients without Fetch Metadata.
  • [ ] Framework CSRF tokens are on for forms; every exception is documented.
  • [ ] Rejections are logged with reason and path.
  • [ ] CI runs cross-site, same-origin and missing-header test requests.
  • [ ] Sibling subdomains that host user content are reviewed or isolated on another site.

FAQ

Is SameSite=Lax enough on its own?

For many apps it blocks the classic attack, but it does not cover same-site subdomains, state-changing GET routes or cookies without the attribute. Treat it as the first layer, not the only one.

Can an attacker fake the Sec-Fetch-Site header?

Not from a victim's browser, because web pages cannot set Sec- headers. A non-browser client can send anything, but it also does not have the victim's cookies, which is what CSRF needs.

Do I still need CSRF tokens if I use Fetch Metadata?

For modern browsers the Fetch Metadata guard does most of the work. I keep tokens for server-rendered forms and as a fallback where older clients matter. They are cheap when the framework provides them.

Does CORS protect me from CSRF?

No. CORS controls whether a page can read a cross-origin response. A simple cross-site form post can still be sent. Strict CORS helps, but it is not a CSRF defense.

What about login CSRF?

Protect the login form too, so an attacker cannot sign a user into the attacker's account. The same guard and token apply, and rotating the session ID at login (Part 2) limits the damage.

Do passkeys remove CSRF risk?

No. Passkeys protect the sign-in step. CSRF abuses the session that exists after sign-in, so the session cookie still needs these protections.

Next in this series, Part 4 covers account recovery hardening: the side door next to your passkey.

Follow & Connect