CSRF Protection: Tokens, SameSite Cookies, and Modern Browser Defenses
CSRF Protection stops forged state changes from other sites. Learn tokens, SameSite cookies, and where browser defaults still leave a gap you must close.
CSRF Protection stops another site from riding a logged in browser into a state change on your API. It matters because the browser will attach your cookies to a cross site request unless you say otherwise. The call can look like a normal post. You must prove the caller is your own page, not only that a cookie exists.
If you change data on GET, browser defenses will not match your design. Then a simple navigation can trigger a write. Also, a token that never changes is just another cookie under a new name.
What the attack depends on
The browser stores your session cookie and sends it when the cookie rules match. Another site can cause the browser to submit a form or a fetch to your host. Your server sees a valid cookie and may perform the action. Because the user is logged in, the bug is about intent, not about a stolen password.
In my experience, teams add SameSite and delete the token. However, SameSite Lax still sends the cookie on some top level cross site navigations. As a result, a state change on GET remains unsafe. You should keep a token or an origin check on every write, even when Lax is on.
This control is for browsers. A script with the raw cookie does not need a forged page. That case is session theft, and it needs different fixes, such as theft resistant cookies and short sessions. Do not expect a CSRF token to fix a stolen cookie.
What counts as a state change
Treat POST, PUT, PATCH, and DELETE as writes. First, require a defense on each of them. Next, search for GET handlers that create, update, or delete.
Move those actions to a write verb. If a legacy client cannot move yet, protect that GET as if it were a post until you retire it.
Still, a read can be sensitive. CSRF is about unwanted actions, not about reading JSON across origins. Reading is the job of same origin rules and CORS rules. You still should not leak data, but the token pattern is aimed at writes.
Architecture that holds up
Use two layers that fail in different ways. The cookie layer uses SameSite, Secure, and a tight host scope. The request layer uses a token or a checked Origin on writes. If one layer is mis-set, the other still blocks the common case.
A common mistake I have seen is to store the CSRF token in a cookie and also send that same cookie back with no second copy. Then the browser attaches it for you, and the attacker does not need to read it. Specifically, the classic double submit pattern needs a value the attacker cannot set on your host. A signed token bound to the session is easier to reason about.
Issue the token with the session. Tie it to the session id. Rotate it when the user signs in again.
For example, a login response can set a new token and discard the old one. Do not reuse one token for every user.
A rollout that does not break the site
- Add the token check in report mode and log misses.
- Update your web client to send the token on writes.
- Fix mobile or partner clients that share the cookie.
- Enforce the check on one write route, then on all writes.
- Set SameSite and Secure on the session cookie in the same change window.
When you enforce first and update the client second, you will lock out users. After the client ships, enforcement is a small step. Although report mode feels optional, it shows which jobs and tests still omit the header.
Trade-offs among the defenses
No single flag covers every browser and every client. SameSite is strong for classic cross site form posts. Tokens cover custom clients you own.
Origin checks are simple when you do not need cross site writes. You should combine two, and know which client each one serves.
| Defense. | Use it when. | Main risk. | What you configure. |
|---|---|---|---|
| SameSite Lax. | You want a safe cookie default. | Top level GET still sends the cookie. | Set Lax on the session cookie. |
| SameSite Strict. | The app never needs cross site entry. | Links from email look logged out. | Set Strict and test inbound links. |
| Session bound token. | You control the web client. | A script on your origin can read it. | Send the token on write headers. |
| Origin check. | Writes should come from your site. | Some clients omit Origin. | Allow only your site origins. |
| SameSite None. | A real cross site flow needs the cookie. | CSRF returns unless a token exists. | Require Secure and a token. |
If the whole product is one site, Strict plus a token is a solid pair. If you must embed a flow on a partner site, None plus a token is the honest setup. Instead of None on the main session, use a separate cookie with a narrow path.
Origin checks fail closed when the header is missing on a browser write. Some older clients omit it. Therefore keep the token as the primary check, and use Origin as a backup you log. Do not accept a missing Origin as proof of safety.
Pitfalls and failure modes
Subdomains bite cookie scope. If the cookie is set for the parent domain, a weak app on a sibling host can send it. Set the cookie host as narrow as you can. Also, do not run untrusted content on a sibling that shares the parent cookie.
Tokens leak into logs and referrers when you put them in the query string. Keep the token in a header or a form body. Redact it in access logs. Also, do not store it in a URL template that support tools copy.
A token that is not compared in constant time is a minor bug next to a token that is not checked on one route. Inventory write routes with a test. A new handler should fail the build if it mutates data and skips the check. Framework defaults help only when every route uses the framework.
Browser defaults you should not trust alone
Modern browsers default cookies toward Lax when you omit SameSite. That blocks many classic forgeries, and it is not a full policy. You should still set the attribute on purpose so older clients and odd flows stay defined.
Also, set Secure and HttpOnly on the session cookie. HttpOnly does not stop CSRF, but it cuts script theft of the cookie.
- Writes served on GET.
- Session cookie scoped to a parent domain.
- CSRF token accepted from a query string.
- One admin route missing the check.
- SameSite None without a second defense.
Cross site script on your own origin can read a token from the page and call the API. CSRF controls do not fix that. You need XSS prevention so the token stays inside your app.
We once hit a bottleneck when a token check called a remote session store on every post. Cache the session hash locally with a short life so the check stays cheap and still bound to the login.
Login CSRF is the reverse case. An attacker starts a session on their account and forces your browser to use it. Then you type secrets into their account. Rotate session ids on login, and reject session cookies that were set in a cross site way if your product allows that pattern.
A cookie and check you can adapt
The snippet sets a session cookie with explicit flags and shows the check shape in words, not a library. The token compare must use your real session store. Do not copy the placeholder secret. Also, reject the write when the header is missing.
# Session cookie. HttpOnly blocks script reads of the cookie.
# SameSite=Lax is the default defense. Still require a token on writes.
Set-Cookie: session=SESSION_ID; Path=/; Secure; HttpOnly; SameSite=Lax
# On each state changing request:
# 1. Read the session and the expected token from the server store.
# 2. Read the token from a header, not from a query string.
# 3. If the header is missing or does not match, return 403.
# 4. Do not log the token value.
Wire a test that posts a write with only the cookie and expects a reject. Wire a second test that sends cookie plus token and expects success. Then run those tests on every new route that mutates state.
The OWASP CSRF Prevention Cheat Sheet compares tokens, SameSite, and custom headers. RFC 6265 is the cookie base spec. When a browser build disagrees with your assumption, check the cookie flags first.
Performance, scale, and cost
A local token compare is cheap. A remote lookup on every write is not. Therefore keep the expected token next to the session record you already load for auth. Do not add a second network hop only for CSRF.
SameSite costs nothing at runtime. It is a set cookie attribute. The cost shows up as broken flows when Strict blocks a real entry path.
Test password reset links and payment return URLs before you flip Strict. Lax is the usual default for a reason.
At scale, log misses with a counter, not a full request dump. Token values must stay out of logs. A spike in rejects after a deploy often means the client and the server disagree about the header name. Roll the client and server together.
What else you still need
A web application firewall can rate limit odd posts, and it cannot see user intent. Do not count on it as the CSRF control. Serve cookies only over TLS so the Secure flag is honest.
The MDN Set-Cookie page lists SameSite, Secure, and HttpOnly in one place. Use it when you review a framework default. Also, confirm partner APIs that are not browsers use their own auth and do not rely on the session cookie.
Cost is engineering time and a few tests, not a new cluster. Budget a week to inventory write routes if the app is old. After that, a middleware check keeps the cost flat as routes grow.
Key Takeaways
- Prove intent on every state change, not only the presence of a cookie.
- Set SameSite on purpose, and still use a session bound token.
- Do not change data on GET.
- Keep cookie scope on the narrowest host that works.
- Send tokens in a header or body, never in the query string.
- Treat SameSite None as a special case that still needs a token.
- Remember that script on your origin can bypass a token. Stop XSS too.
FAQ
Is SameSite Lax enough by itself?
It blocks many classic cross site posts. It still sends the cookie on some top level GET navigations. If any GET changes state, Lax is not enough. Keep a token or move the write to a safer verb.
Where should the token live?
Keep the expected value on the server, bound to the session. Send a copy in a header on writes. Do not put it in the URL. HttpOnly is for the session cookie, not a reason to skip the token.
Do JSON APIs need this if they use bearer tokens?
If the browser does not store the credential in a cookie, classic CSRF does not apply. The page must attach the bearer token on purpose. If you also set a session cookie, protect the cookie paths. Do not mix both and check neither.
What should a failed check return?
Return a client error and do not perform the write. Use one status so the client can refresh a token and retry once. Log the route and the reason code, not the token. Alert if rejects spike after a release.
CSRF Protection is a cookie flag plus a check that your own client can pass and a random site cannot. List every handler that writes. Mark the ones that still rely on a cookie alone.
Next, add a session bound token and enforce it on those handlers. Then set Secure, HttpOnly, and SameSite on the session cookie. After that, add a test that fails when a new write route skips the check.
Last updated on 22 September 2026.
[…] is not a browser. A server side job can POST to you with no preflight at all. That is why auth, CSRF protection, and input checks still matter. The browser rule is only for browser […]