Build CSRF Protection That Survives OAuth Callbacks
HMAC-signed CSRF tokens plus the SameSite settings that block forged requests without breaking your Slack or Stripe login.
Cross-site request forgery is the attack where a malicious page makes a request to your application using the victim's logged-in session, without the victim ever knowing. A hidden form on an attacker's site auto-submits a POST to your "change email" endpoint, the browser dutifully attaches the victim's session cookie because the request goes to your domain, and the email gets changed. The victim did nothing but visit the wrong page.
The defenses are well understood, and they are also where teams trip over their own feet, because the strictest-looking setting, SameSite=Strict, silently breaks the most common login flow on the modern web: signing in with Slack, Google, GitHub, or Stripe. So you have to build CSRF protection that actually stops forged requests while still letting your OAuth callbacks complete. Here is how the pieces fit.
SameSite is the first line, and Strict is a trap
The SameSite cookie attribute tells the browser when to attach a cookie to a cross-site request. It has three values, and choosing between two of them is where most of the damage happens.
SameSite=Strict means the cookie is never sent on any cross-site request, including a top-level navigation where the user clicks a link from another site to yours. That sounds maximally safe, and for CSRF it is. But it breaks OAuth. When a user signs in with Google, your app redirects them to Google, Google authenticates them, then Google redirects back to your callback URL. That final redirect is a top-level navigation originating from Google's domain, which the browser sees as cross-site. With SameSite=Strict, your session cookie is not sent on that redirect, so your callback runs with no session, the OAuth state parameter you stored cannot be found, and the user is bounced back to login as if they never authenticated. This is not theoretical; it is a recurring bug report against major OAuth libraries, surfacing as "invalid state parameter" or "not authenticated" mid-flow.
SameSite=Lax is the right default for session and auth cookies. It blocks the cookie on cross-site form POSTs, fetches, iframes, and image loads, which is where CSRF attacks actually come from, while still sending it on top-level cross-site GET navigations, which is exactly what an OAuth callback redirect is. You keep the CSRF protection against the attack vectors that matter and you keep OAuth working.
We learned this the expensive way: a production session cookie set to Strict meant the Slack OAuth callback arrived with no session, the app treated the user as signed out in the middle of connecting an integration, and the fix was a one-word change from strict to lax. It is the cookie-attribute cousin of why your OAuth login breaks with SameSite Strict and how to fix it. The CSRF token middleware was already protecting mutations, so loosening SameSite did not actually weaken anything. It just let the legitimate cross-site navigation through.
SameSite=None; Secure exists for the rare cookie that genuinely must travel cross-site, like a temporary state cookie during an OAuth flow that bounces across domains. Keep its lifetime to a few minutes and delete it the moment the flow completes.
SameSite is not the whole defense
Here is the part teams get wrong in the other direction: treating SameSite=Lax as the entire CSRF story. It is a strong secondary mitigation, not the primary one. Good defense stacks; it does not swap.
The reason you still need real CSRF tokens is that Lax has edge cases and exceptions, browser behavior varies, and some legacy clients and configurations weaken the guarantee. More to the point, Lax permits top-level cross-site GETs, so any state change reachable by a GET is unprotected by SameSite alone. The token is the defense that does not depend on cookie behavior. So the rule is: every state-changing request, every POST, PUT, PATCH, and DELETE, carries a CSRF token that the server verifies, regardless of what SameSite is set to.
HMAC-signed tokens that are stateless and verifiable
The token pattern that holds up is a signed token, sometimes called a synchronizer token, built so the server can verify it without storing per-session state.
Construct the token as an HMAC-SHA256 signature over a nonce and a timestamp, keyed with a secret dedicated to CSRF and separate from your JWT or session secrets, the same separation-of-concerns principle behind why one leaked secret should never compromise the rest. The token is the nonce, the timestamp, and the signature. On a state-changing request, the client sends the token, and the server recomputes the HMAC over the nonce and timestamp and compares it to the signature using a constant-time comparison. If they match and the timestamp is within the validity window, say one hour, the request is genuine. If not, it is rejected.
This gives you several properties at once. The signature means an attacker cannot forge a valid token without the CSRF secret, which never leaves your server. The timestamp means a captured token expires quickly. And because the token is self-verifying through the HMAC, you do not need a server-side store of issued tokens, which keeps the mechanism stateless and fast. The constant-time comparison matters: a naive === on the signature leaks, byte by byte through timing, information an attacker could use to forge a match, the same timing-safe discipline behind hashing passwords with scrypt and a timing-safe comparison.
A practical flow: expose a /api/csrf endpoint that issues a fresh token, have the client fetch it before any mutation, and include it in a header on every state-changing request. The server's middleware verifies the header on every POST, PUT, PATCH, and DELETE before the handler runs. Building this into the request pipeline rather than sprinkling checks into individual handlers is part of how we structure the web applications we ship, because a CSRF check that lives in middleware cannot be forgotten on the one new endpoint someone adds next month. It sits alongside the other baseline protections like the security headers every Next.js app should ship, all applied at the pipeline level so no route opts out by accident.
What to exempt, and what never to
CSRF tokens protect requests that ride on the user's ambient session cookie. A few endpoints legitimately do not, and forcing a token on them breaks them for no security gain.
Webhooks. A payment provider or third-party service calling your webhook endpoint has no CSRF token and no session cookie, and CSRF is not the threat model for an automated server-to-server call. Webhooks carry their own signature, an HMAC the provider computes over the payload with a shared secret, and you verify that instead. Exempt webhooks from CSRF and verify their signatures rigorously, because the signature is doing the same job the CSRF token does for browsers, exactly the discipline of verifying payment webhooks before they move money.
The login endpoint itself. A user submitting credentials does not yet have a session, so there is nothing for CSRF to forge against. Exempt login from the CSRF requirement, but keep it behind progressive rate limiting and the other authentication protections.
Everything else that changes state stays protected. The temptation, when a new endpoint throws a CSRF error during development, is to exempt it to make the error go away. That is the moment the protection unravels. The right move is to make the client send the token, not to remove the requirement.
The shape of a setup that holds
A CSRF defense that actually works has two layers doing different jobs. SameSite set to Lax on your session cookie blocks the forged cross-site POSTs and fetches that make up most CSRF attacks, while still letting the legitimate top-level GET navigation of an OAuth callback through, so your Slack and Stripe and Google sign-ins complete instead of dying on an empty session. On top of that, HMAC-signed tokens on every state-changing request give you the real, cookie-independent guarantee, verified in middleware so no new endpoint can quietly opt out. Webhooks and login are the only exemptions, and webhooks pay their way with signature verification of their own.
Get those two layers right and the forged request from the attacker's hidden form arrives with no valid token and, on the vectors that matter, no cookie either. It fails twice over, while the user signing in with their Google account never notices the machinery at all. That is the whole goal: invisible to the legitimate user, immovable to the attacker.






