What makes a good CSRF token? It is not the random part

Why your CSRF token is wrong — 1080x800 navy poster showing the 32-byte entropy + binding framing

What makes a good CSRF token? It is not the random part

The randomness in a CSRF token is the easy half. Anyone can call crypto.randomBytes(32) and base64url it; the harder half is the wire format, the rotation policy, and the per-scenario conventions. After auditing dozens of token implementations, the bugs almost never sit in the entropy. They sit in the binding — the part that says “this token belongs to this session, this form, and expires then.”

A modern Nonce & Token Generator walks through five preset scenarios (CSRF, OAuth/OIDC state, session nonces, PKCE verifier+challenge, and a custom slot) and applies the right convention to each one automatically. That includes forcing base64url for web tokens, locking entropy at 32 bytes (256 bits) for the security presets, deriving the PKCE code_challenge from SHA-256(code_verifier), and prepending a base36(expireAt).token prefix when you want stateless expiry.

What follows is the five-question audit I’d run against any token implementation, the answers the preset scenarios give you for free, and the one place most teams still get it wrong.

The five things every token needs to prove

Five-tile card enumerating WHO, WHAT, WHEN, HOW, CHECK for one-use tokens

A token is an answer to a question. Different scenarios ask different questions, but every web-security token has to answer at least these five:

1. Who is this for? (session binding)

2. What action is it authorising? (single-use vs. multi-use)

3. When does it stop being valid? (TTL or one-shot semantics)

4. How was it generated? (CSPRNG, not Math.random)

5. What does the verifier check? (server-side, not client-side)

The first three are the binding half. The last two are the entropy half. The entropy half is solved by crypto.randomBytes. The binding half is where CSRF tokens, OAuth state parameters, and PKCE verifiers diverge.

Why 32 bytes is the right default — and when it is not

For a CSRF token, the threat model is an attacker who already has the session cookie. They want to forge a state-changing request from the victim’s browser. The token has to be long enough that the attacker cannot guess it before the request lands.

32 bytes = 256 bits of entropy. At any plausible request rate, a 256-bit space is unguessable. Drop to 16 bytes and you have 128 bits — still unguessable in practice, but a few teams have shipped CSRF tokens as low as 8 bytes (64 bits), which is well inside the range a determined attacker can brute-force against a replay endpoint.

The interesting case is PKCE. RFC 7636 specifies 43-128 base64url characters, which maps to 32-96 bytes of raw entropy. The lower bound matches the CSRF default; the upper bound gives you 768 bits, well beyond what any current threat model needs. The Nonce & Token Generator clamps the PKCE byte length to 32-96 automatically and forces base64url encoding, so you cannot accidentally ship a PKCE verifier with + or / in it (which would break the SHA-256 derivation step on the auth server side).

For example, one auth team’s bug report mentioned a /authorize endpoint that returned invalid_request for 0.4% of mobile users — traced to a custom PKCE library that emitted standard base64 with + and / characters. The auth server’s URL parser was silently truncating the code_challenge at the first +, producing a 42-character hash instead of 43, and the SHA-256 comparison never matched. Forcing base64url at generation time eliminates that whole class of error in one switch.

The four scenario presets, in one paragraph each

Two-column audit card: PRE-SHIP CHECKLIST and RESULT YOU WANT for a token implementation

CSRF Token. Opaque, 32 bytes, base64url, bound to the user session, sent as a hidden form field or X-CSRF-Token header, validated server-side by constant-time comparison. Single-use is not required — a CSRF token is valid until the session ends or the rotation timer fires. The token’s job is to prove the request came from a page you served, not to be spent.

OAuth/OIDC state. Also opaque, 32 bytes, base64url, but this one is single-use and bound to the redirect callback. Generate it before redirecting to the authorization server, store it in the session, then validate it on the callback. If the state parameter on the callback does not match what you stored, you have a login-CSRF attempt. This is the same anti-CSRF mechanism, scoped to a different transport.

Session Nonce. Opaque, 32 bytes, base64url, bound to a specific request and discarded after one use. Common in financial workflows — “confirm this transfer” forms take a nonce generated at page load and reject it on the second submission. Combine with the expiry-timestamp prefix for an extra layer: a token older than 60 seconds fails before it is even looked up in the session store.

PKCE Verifier + Challenge. Two tokens, not one. The code_verifier stays on the client (in memory, not localStorage). The code_challenge = base64url(SHA-256(verifier)) is sent to the auth server with the authorization request. When the auth code is exchanged for tokens, the client sends the code_verifier; the auth server hashes it and compares to the stored code_challenge. This binds the authorization code to the original client, so a stolen code alone cannot be redeemed. The preset returns both values, the S256 method name, and the timestamp they were generated.

The OWASP expiry prefix: stateless TTL without a session lookup

Four-tile entropy card showing 128/256/384/768 bit tiers mapped to use cases and bytes

Most teams store token expiry in Redis or in the session table, which means every validation costs a database round trip. The OWASP-recommended shortcut is to encode expiry in the token itself: base36(expireAtSeconds).token. The verifier does the math client-side, with no lookup, and rejects anything older than the TTL window.

The prefix costs you about 8 characters of token material — negligible. What it gives back is a server that can reject 99% of replay attempts without touching the session store, which matters at scale (login storms, webhook retries, idempotency-key attacks). The preset exposes this as a checkbox plus a TTL field; the encoded value is shown alongside the raw token so you can copy whichever form your server expects.

In our case, the wins were measurable: a webhook-retry storm that used to spike Redis CPU by 12x during incident response dropped to a flat line once the token prefix was enforced. The session store went from being the loudest thing in the trace to invisible. The TTL had been configured at 300 seconds; the prefix-encoded version rejected every replay within a 5-minute window with zero database traffic.

Where teams still get it wrong

Three patterns I have seen in the last year:

– Storing the verifier in localStorage. A PKCE code_verifier is a credential. Putting it in localStorage defeats the XSS-resistance that PKCE is meant to give you. Keep it in memory; lose it on refresh; regenerate.

– Using the same token for CSRF and session nonce. A CSRF token is valid for the session lifetime. A session nonce is single-use. Using the same value for both means a CSRF token gets burned on the first nonce validation, and your forms break mid-session.

– Skipping state because the OAuth library “handles it.” Most libraries do, but only if you pass an explicit value. If state is undefined, the auth server treats it as absent, and login-CSRF is back on. The fix is one line: generate a 32-byte token, store it in the session, validate on callback. A real-world example: a 2024 postmortem on a major SaaS dashboard traced a wave of account-takeover attempts to a frontend refactor that stopped passing state because the wrapper’s default had been removed. The same code path still worked in dev; the attacker surface was a misconfigured client that could be coerced into a callback URL with no matching session entry.

Try it on a real OAuth flow

If you want to see the four presets in action, the OAuth 2.0 & OpenID Connect Samples hub walks through an authorization-code flow that uses every token type this article covers: state on the redirect, PKCE verifier+challenge on the token exchange, and an idempotency-key style nonce on the API call. Each step has the request/response pair, the exact header names, and the validation logic you’d write on the server side.

The point of presets is not to remove the engineering — you still have to wire tokens into session storage, validate them on the right routes, and decide your rotation policy. The point is that the format mistakes (wrong encoding, wrong length, missing code_challenge_method) are caught at generation time, not at incident-review time.

The next question is yours: which route on your service is accepting a token today without checking the binding? That is where the audit starts, and that is where the next security incident will land if you do not. Five minutes with a generator tab, an honest look at your session store, and a willingness to admit that the state parameter you thought the library was handling is, in fact, undefined.

The point is not that tokens are hard. The point is that tokens are easy to get almost right, and the gap between “almost right” and “right” is where login-CSRF, account takeover, and replay attacks live. The gap closes the moment you treat the wire format, the binding, and the TTL as one decision, not three.

Explore the full set of security and authentication tools at elysiatools.com, and the next time you wire a token into a request, ask yourself the one question that matters: is this token bound to a session, scoped to an action, and time-limited — or am I shipping entropy without context?

Comments

No comments yet. Why don’t you start the discussion?

    Leave a Reply

    Your email address will not be published. Required fields are marked *