background-shape
CSRF Defense Patterns in 2022
November 23, 2022 · 4 min read · by Muhammad Amal programming

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:

  1. Server sets a csrf cookie with a random value
  2. Client JS reads the cookie, includes its value in a X-CSRF-Token header
  3. 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:

  1. SameSite=Lax cookies — defeats most CSRF
  2. Custom header check OR CSRF token — defense in depth
  3. CORS restriction — narrow allowed origins
  4. 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/csrf etc.).
  • 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.