CSRF Defense Patterns in 2022
TL;DR — SameSite=Lax cookies + custom header check on state-changing endpoints = sufficient for most apps. Add CSRF tokens for paranoid setups. CORS does NOT prevent CSRF. The pattern is “request must come from your origin” — multiple complementary checks.
After CORS, CSRF — the related but distinct threat. Misunderstood; over-defended in some apps; under-defended in others.
What CSRF is
Attacker tricks a logged-in user’s browser into making a request to your site:
<!-- on attacker.com -->
<img src="https://yourbank.com/transfer?to=attacker&amount=1000">
Browser sends the GET. The user’s cookies for yourbank.com go along (browsers auto-send cookies). Bank processes it as if the user clicked.
Defense: ensure state-changing requests come from your own site, not other origins.
What CSRF is NOT
- Authentication. The user is already logged in; CSRF abuses that.
- CORS-protectable. CORS controls who can READ responses; CSRF abuses requests being SENT.
- Limited to GET. Forms POST cross-origin natively; XHR/fetch POSTs do too (with preflight for non-simple).
SameSite cookies — the modern default
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax
Three SameSite values:
- Strict: cookie never sent on cross-origin requests
- Lax: cookie sent on top-level GET navigations from external sites (e.g., clicking a link). Not on other cross-origin requests.
- None: cookie sent on all cross-origin requests (must be
Secure)
Most modern browsers default cookies to SameSite=Lax if no SameSite attribute is set. Chrome did this in 2020.
SameSite=Lax defeats most CSRF attacks. Cross-origin POST? Cookie not sent. Iframe form submit? Cookie not sent. Image src? GETs sent but no cookie.
For most apps in 2022: SameSite=Lax + HttpOnly + Secure is the foundation. Old advice about CSRF tokens assumed pre-SameSite browsers.
Synchronizer token pattern
Server includes a CSRF token in every form. Server expects it back on submit:
<form action="/transfer" method="POST">
<input type="hidden" name="csrf_token" value="abc123xyz">
...
</form>
Server:
def transfer(req):
if req.form["csrf_token"] != session["csrf_token"]:
raise Forbidden("CSRF")
# process
The token is unguessable; attacker on another site can’t include it. Even if cookies are sent, no token = rejected.
Per-session token or per-form. Per-form is more secure but more overhead. Per-session is fine.
Frameworks ship this out of the box: Rails (protect_from_forgery), Django (@csrf_protect), ASP.NET, Express + csurf, etc.
Double-submit cookies
For stateless backends:
- Server sets a
csrfcookie with a random value - Client JS reads the cookie, includes its value in a
X-CSRF-Tokenheader - Server verifies the cookie value equals the header value
Cookie: csrf=abc123
X-CSRF-Token: abc123
Since attackers on other origins can’t read your cookies (SOP) AND can’t set arbitrary headers cross-origin, they can’t make this match.
Less server-side state than synchronizer tokens. Works well for SPAs.
Custom header check
Simplest:
fetch('/api/transfer', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({...}),
});
Server requires X-Requested-With header on state-changing endpoints.
Why this works: setting a custom header on a cross-origin request triggers CORS preflight. Attacker’s malicious page can’t preflight without your CORS server allowing it; therefore can’t set the custom header.
This is the laziest valid CSRF defense; it requires CORS to be restrictive (no * for origins).
The layered approach
Real applications combine:
- SameSite=Lax cookies — defeats most CSRF
- Custom header check OR CSRF token — defense in depth
- CORS restriction — narrow allowed origins
- POST only for state changes — never GET; not the right verb anyway
Each is incremental. Together they’re robust.
Endpoints that don’t need CSRF protection
- Public read-only endpoints. No state change; not interesting to CSRF.
- API endpoints with
Authorization: Bearer(not cookies). Bearer tokens aren’t auto-sent by browsers. Attacker can’t include the token cross-origin. CSRF doesn’t apply. - Server-to-server endpoints with API keys. No cookies involved.
If your API uses Authorization: Bearer tokens exclusively (no cookies), you don’t need CSRF tokens at all. The auth model is immune.
If you use cookies (session cookies): CSRF defense required.
Frameworks
For 2022:
- Django: built-in. Enabled by default; don’t disable.
- Rails: built-in.
protect_from_forgery. - Express + csurf: enable explicitly.
- Spring Security: enabled by default in newer versions.
- Next.js: rolls your own. Use SameSite cookies + custom header.
- Go (chi / gin / fiber): middleware libraries (
gorilla/csrfetc.). - Laravel: built-in.
Use the framework’s. Don’t roll your own crypto.
Common Pitfalls
Believing CORS prevents CSRF. Different threat models.
Cookies without SameSite attribute. Old defaults; manage explicitly.
CSRF tokens validated as plain string comparison. Use constant-time comparison to prevent timing attacks.
Per-token everywhere even for read-only. Annoyance without value.
Auth via cookie + no CSRF defense. Classic Web app vulnerability.
JSON body assumed safe. Modern fetch posts JSON cross-origin (with preflight); attackers can use form encoding to skip preflight. Don’t rely on Content-Type alone.
Wrapping Up
SameSite=Lax + custom header (or CSRF token) + CORS = layered CSRF defense. Most frameworks handle it; use theirs. Friday: input validation and OWASP Top 10.