Skip to content
CWE-352A01:2021 – Broken Access Control

The Synchronizer Token

A server-side token bound to the session, plus the lifecycle questions: per-request versus per-session, rotation, BREACH masking, and back-button breakage.

The one defence that does not depend on the browser

The synchronizer token pattern is the primary CSRF defence and has been for twenty years. The server generates an unpredictable value, stores it against the session, embeds it in every form it renders, and requires it back on every state-changing request. An attacker's page cannot read the value — the same-origin policy stops it — so it cannot construct a request that passes.

What makes it worth keeping in 2026, when SameSite covers a lot of the same ground:

  • It does not depend on browser behaviour. No Lax defaults, no engine divergence, no Lax+POST window, nothing to re-verify when a vendor changes policy.
  • It survives a same-site attacker. A malicious or compromised subdomain is same-site, so SameSite sends the cookie happily. It still cannot read the token out of the target's HTML.
  • It covers state-changing GET, which Lax deliberately does not.

Those three gaps are exactly where SameSite fails, which is why OWASP's guidance is to use a token and SameSite, not one or the other. See Defense in Depth.

What the token must satisfy

Each of these is a real bug seen in production; each has its own row in Token Validation Flaws.

  1. Unpredictable. Generated from a cryptographically secure RNG. Not a counter, not a timestamp, not a hash of the username, not the session ID itself.
  2. Bound to the session. The server must verify this token belongs to this session. A token that is merely well-formed, or drawn from a global pool, lets an attacker use their own valid token in the victim's request. This is the single most common real failure.
  3. Checked on every state-changing request. Every route, every method that is not GET/HEAD/OPTIONS. A default-deny middleware, not a per-route opt-in — anything opt-in will be forgotten on a new route.
  4. Compared in constant time, to avoid leaking it a byte at a time through timing.
  5. Rejected when absent. An empty or missing token must fail. A check written as if (submitted && submitted !== expected) reject() passes when the parameter is simply deleted.
  6. Not leaked. Never in a URL — it lands in Referer, browser history, and server logs. In the body or a header only.

The flow

HTTP
# 1. The server renders a form and stores the token against the session.
GET /account HTTP/1.1
Host: app.example
Cookie: __Host-session=eyJ1c2VyIjoidmljdGltIn0

HTTP/1.1 200 OK
Content-Type: text/html

<form action="/account/email" method="POST">
  <input type="hidden" name="csrf_token" value="kJ8xR2mN...">
  <input name="email">
</form>

# 2. The legitimate submission carries it back.
POST /account/email HTTP/1.1
Host: app.example
Cookie: __Host-session=eyJ1c2VyIjoidmljdGltIn0
Content-Type: application/x-www-form-urlencoded

csrf_token=kJ8xR2mN...&email=new@example.com

# 3. The forged request cannot. attacker.example is not allowed to read the
#    HTML of step 1, so it has no value to put here.
POST /account/email HTTP/1.1
Host: app.example
Cookie: __Host-session=eyJ1c2VyIjoidmljdGltIn0
Origin: https://attacker.example

email=attacker@evil.example

HTTP/1.1 403 Forbidden

Per-session or per-request?

A recurring design argument. The short answer: per-session is the right default, and per-request is a niche choice that causes more problems than it solves.

Per-session — one token for the life of the session.

  • Works with multiple tabs, the back button, and page caching.
  • Simple to reason about and to test.
  • If leaked, valid until the session ends.

Per-request — a fresh token on every response, the previous one invalidated.

  • Narrows the window for a leaked token.
  • Breaks multiple tabs. Tab A's token is invalidated when tab B loads, so submitting in tab A fails. Users see random logouts and errors.
  • Breaks the back button. Going back to a cached form yields a stale token.
  • Encourages a bad workaround: teams hit the breakage and "fix" it by accepting the previous N tokens, or by falling back to accepting a missing token — reintroducing the vulnerability.

OWASP's guidance no longer recommends per-request as a general practice. Use per-session, and if a specific operation needs stronger assurance, add re-authentication for that operation rather than a token treadmill for the whole app.

Do rotate on privilege change — at login especially. A token that survives login unchanged enables session fixation (Login CSRF).

Implementation

Pythonviews.pyVulnerable
import hashlib

def make_token(request):
    # Predictable: derived from the username, so it is the same on every
    # request and an attacker who knows the victim's username can compute it.
    return hashlib.md5(request.user.username.encode()).hexdigest()

def change_email(request):
    submitted = request.POST.get('csrf_token')

    # Three separate bugs in one line:
    #  1. `if submitted and ...` -- deleting the parameter entirely skips the
    #     check completely and the request proceeds.
    #  2. `!=` is not constant-time.
    #  3. The token is not bound to the session in any way -- it is only
    #     compared to a value recomputed from public information.
    if submitted and submitted != make_token(request):
        return HttpResponseForbidden()

    request.user.email = request.POST['email']
    request.user.save()
    return redirect('/account')

Masking and BREACH

Django and Rails both emit a masked token that differs on every render, rather than the raw session token. The reason is BREACH: a compression side-channel that can recover a secret which appears verbatim in many compressed responses. Masking means the byte sequence changes each time, so there is nothing stable to attack.

The usual construction is a random per-render pad XORed with the real token, with the pad sent alongside; the server XORs them back before comparing.

Two practical consequences:

  • Do not compare rendered tokens to each other. Two renders of the same session's token look completely different and are both valid. Testers sometimes report "the token changes on every reload" as a finding — it is not.
  • If you hand-roll, either mask, or disable compression on responses containing the token, or make sure the token is not reflected into the body more than necessary. Spring Security ships XorCsrfTokenRequestAttributeHandler for exactly this.

Checklist

  • Use the framework's implementation. Every mainstream one already handles entropy, binding, constant-time comparison, and masking — see the Cheatsheet for what each does by default.
  • Apply it as default-deny middleware, never per-route opt-in.
  • Keep the exemption list empty, and review it in code review when it is not. Every entry is a route with no CSRF protection.
  • Per-session tokens, rotated on login and privilege change.
  • Never put the token in a URL.
  • Constant-time comparison, and reject empty or missing outright.
  • Pair it with SameSite=Lax and a __Host- prefixed cookie — the token covers what SameSite misses, and vice versa.