Authentication · beginner
CSRF token rotation drift: explain valid forms becoming invalid
CSRF token rotation drift happens when the browser and the application server disagree about which generated anti-forgery token is current, so a form that was just rendered is rejected on submit. The mismatch is rarely an attack signal and almost always a synchronization problem between cookie/session state and the token bound to the visible form.
The symptoms
- •Form submission fails immediately with 419 (or framework-specific "Page Expired" / "Invalid Token") even though the page loaded successfully seconds earlier and shows no JavaScript errors.
- •The same authenticated browser session can load the form repeatedly, but the second or later POST within minutes returns the CSRF rejection while the first one succeeds.
- •Multiple browser tabs of the same authenticated app submit the same form, but a stale tab's submission fails while the freshly opened tab succeeds, with identical user credentials.
- •Intermittent rejections that correlate with idle time, cache flushes, or login/logout transitions, rather than with any specific user action or input.
- •Log entries that show a valid session and valid cookie, yet the request body contains a token whose generation timestamp or session binding does not match the session that received it.
Likely causes
- •The session identifier is rotated (for example on login, privilege change, or timeout) while the CSRF token still references the previous session generation, producing an orphaned token.
- •The CSRF cookie and the form-hidden-token are generated independently and the cookie is refreshed on every response (as the Set-Cookie specification permits) but the body token is cached, snapshotted, or rendered from a server-side template that did not see the latest cookie.
- •The double-submit pattern is implemented by hashing the session ID into the token, but the server now signs or derives tokens from a different secret after a deployment, rotation, or key change, invalidating earlier tokens without invalidating cookies.
- •A reverse proxy, load balancer, or CDN in front of multiple application nodes hands the user a token generated on node A and then routes the POST to node B, where the token's seed, cache, or session binding is unknown.
- •The browser holds an older CSRF cookie from before a logout/login cycle, but the page is served from a cached HTML fragment that still embeds the pre-cycle token.
- •The application uses a per-request token but the frontend renders multiple forms into the DOM from a single response, and JavaScript re-submits the earlier form after the server has already rotated.
First ten minutes
- 01Capture the exact failure response code (419, 403, or framework-specific) and the response body, and note whether the rejection occurs on POST only, or also on PUT/DELETE/PATCH that carry the same token.
- 02Open the browser's DevTools Application tab and compare the CSRF cookie's value, domain, path, expiry, and the session cookie against what the server believes is current for that request; treat this as the boundary to inspect, not the network tab alone.
- 03Reproduce in a private window with a single tab to remove multi-tab and cached-HTML noise; if it stops failing there, the cause is client-side caching or stale-tab drift, not server logic.
- 04Check whether the rejection timing aligns with login, logout, idle timeout, or a recent deploy by correlating the failed request's timestamp with application and auth event logs.
- 05Inspect the request body and the corresponding form HTML to confirm that the token in the payload is byte-identical to the hidden input that was rendered; if not, the form was mutated or re-rendered after the token was issued.
- 06Decide which boundary is suspect: cookie vs. body token, single node vs. multi-node, single tab vs. multi-tab, or pre-login vs. post-login; do not change CSRF configuration before this decision is recorded.
Evidence to collect
- •The CSRF cookie's Name, Value, Domain, Path, Expires/Max-Age, Secure, HttpOnly, and SameSite attributes as the browser sees them, plus the matching Set-Cookie header from the most recent response that delivered the form.
- •The exact hidden form field name and value submitted, taken from the rendered DOM at the moment of submission, and compared to the value the server recorded for that session.
- •The session ID before and after any login, logout, or privilege change, and whether the CSRF token's binding (hash, signature, or cache key) references the old or new session ID.
- •The application node or instance identifier that served the GET that rendered the form and the application node that processed the POST, when the deployment has more than one node.
- •The timestamps of token issuance, cookie rotation, and request submission, to compute the drift window between when the token became valid and when it was presented.
- •Server-side logs showing the rejection reason string (for example "CSRF token mismatch", "token expired", "session regenerated") and the request's user/session identifier at the moment of rejection.
Where to look
- •At the boundary between the browser's cookie jar and the application's session store, because the Set-Cookie specification allows cookies to be refreshed independently of any body content the server emits.
- •At the boundary between the HTML/template layer that renders the hidden token and the session/middleware layer that validates it, since drift is most often introduced where the two meet out of order.
- •At the boundary between the load balancer or reverse proxy and the application nodes, where session affinity or sticky routing decides whether the validating node has seen the same token seed.
- •At the boundary between the auth lifecycle (login, logout, MFA, password change, privilege escalation) and the CSRF lifecycle, because every auth state change is a candidate rotation point.
- •At the boundary between cached HTML fragments (server cache, CDN, service worker, browser back-forward cache) and freshly generated tokens, since a cached fragment is the classic source of orphaned tokens.
- •At the boundary between frontend JavaScript that re-renders forms and the server's per-request token policy, because client-side re-rendering can submit a token whose generation is already superseded.
Diagnostic steps
- 01Reproduce the rejection with cookie inspection enabled and confirm that the CSRF cookie and the hidden form token were both issued for the same session generation; if they differ, the cause is session rotation, not token forgery.
- 02Compare the Set-Cookie header from the response that delivered the form against the response that processed the submission, and note whether the cookie value or attributes changed between them.
- 03Verify whether the rejection correlates with a login or logout by repeating the flow in a fresh, single-tab private window; if it disappears, the production cause is multi-tab or cached fragment reuse, not server-side token generation.
- 04If the deployment has multiple application nodes, force every request to a single known node (via the proxy's pinning controls, not by URL) and re-run the flow; persistent success on one node isolates the cause to cross-node token/state divergence.
- 05Hash or signature check the token against the session identifier using the application's own derivation, and confirm that the presented token matches the session currently bound to the cookie; mismatch here is decisive evidence of rotation drift.
- 06Capture the rendered HTML at submission time and the cached HTML (if any) that originally delivered the form, and diff the hidden token field; equality rules out DOM mutation, inequality points to a stale cache.
- 07Disable any server-side HTML cache, CDN HTML cache, and service worker for one user, re-run the flow, and observe whether the rejection rate drops; this isolates caching as a contributing boundary without changing auth state.
Common mistakes
- •Concluding the rejection is an attack and tightening CSRF rules before confirming the cause, which can lock legitimate users out and mask the rotation drift that was actually present.
- •Comparing only the token value and ignoring the cookie's attributes (Domain, Path, SameSite, Secure), so a same-named token in a different scope is mistaken for a forged token.
- •Looking at network request timing and ignoring the template/cache layer that produced the hidden field, so a server-side cache that pre-dates the cookie rotation is misread as a client-side bug.
- •Assuming single-tab reproduction rules out caching, when back-forward cache or service worker caches can replay a token after the session has already moved on.
- •Rotating the CSRF secret or key as a "fix" without first checking session binding, because key rotation invalidates every in-flight token simultaneously and turns drift into a hard outage.
- •Treating intermittent 419s as flaky network behaviour and retrying, which can stampede the server with the very tokens that are already known to be stale.
Safe fixes
- •If the drift is caused by session regeneration at login or privilege change, generate the CSRF token after the session is finalized for the new identity and bind the token to the new session identifier before any HTML is rendered; this is conditional on confirming the token references the old session.
- •If multiple application nodes are involved and tokens are derived from per-node state, store the token-to-session binding in shared storage keyed by session identifier, and have every node validate against that shared store; this is conditional on confirming cross-node divergence.
- •If the form HTML is served from a cache that pre-dates a cookie rotation, exclude authenticated form pages from any full-page cache, or include the token in a cache key that changes whenever the CSRF cookie rotates; this is conditional on confirming cached HTML delivery.
- •If JavaScript re-renders forms after the server has already rotated, refetch the form HTML (or a dedicated token endpoint) immediately before submission rather than reusing a snapshot taken at page load; this is conditional on confirming client-side re-render timing.
- •If the cookie's SameSite, Secure, or Path attributes cause the browser to withhold the cookie on the submit URL, correct the attributes so the cookie is actually sent with the request that carries the token; this is conditional on confirming the cookie is not present on the POST.
- •If the token derivation key was rotated, plan a dual-accept window where the server validates against both old and new keys for a bounded time, so in-flight tokens from before the rotation are not all rejected at once.
Prove the fix
- 01Submit the form successfully in a clean, single-tab session, then submit it again from a second tab opened after a login/logout cycle, and confirm both submissions return the application's success response code, not the rejection code observed during the incident.
- 02Submit the same form from three consecutive browser sessions after forcing each request to a different application node (when the deployment has multiple nodes), and confirm that every submission succeeds with a 2xx response and the server log shows the same session identifier in both the rendering and validating nodes.
- 03Open the form, leave the page idle past the session/idle timeout, reload the page, and submit, and confirm the server returns the rejection with a "session expired" reason (expected) rather than a CSRF mismatch (the bug), proving the boundary between session expiry and token drift is now correctly distinguished.
- 04Inspect the rendered DOM at submit time and the server's recorded token for that session and confirm they are byte-identical and reference the same session identifier, with no cached HTML in between.
- 05Re-enable any caching layer that was disabled for diagnosis, repeat the full submit flow, and confirm the success rate and rejection code match the uncached baseline over at least one full rotation cycle.
- 06Run an automated check that fires a POST carrying an explicitly superseded token and confirm the server returns the same rejection code, so future drift regressions surface as a single, recognisable signal rather than as scattered 419s.
Prevention and next steps
- •Treat every auth lifecycle transition (login, logout, MFA, password change, privilege change) as an explicit CSRF token rotation point, and bind the new token to the new session identifier before any HTML is emitted.
- •Keep the CSRF cookie's lifetime, scope, and rotation policy aligned with the session lifetime, so the cookie and the token it shadows cannot drift apart silently.
- •Never serve a form page that contains a CSRF token from a cache whose key does not include both the session identifier and the current CSRF cookie value.
- •Derive or store CSRF tokens in a way that is identical across every application node, so the load balancer does not introduce token drift by routing submissions away from the issuing node.
- •Before any secret, key, or session-store rotation, define an overlap window during which both old and new tokens are accepted, and remove the overlap only after monitoring shows no in-flight rejections.
Safe commands and checks
List the CSRF-related Set-Cookie attributes for the response that delivered the form: capture Name, Value, Domain, Path, Expires/Max-Age, Secure, HttpOnly, SameSite; do not paste cookie values into shared systems. Diff the hidden CSRF input value as rendered in the DOM at submission time against the value the server records for that session, using the framework's server-side template inspection; this is a read-only comparison. Inspect the application and auth event logs for the request's timestamp and filter for the rejection reason string (for example "CSRF token mismatch", "token expired", "session regenerated"); correlate with login/logout/MFA events. Identify the application node that served the GET and the node that handled the POST via the deployment's request routing logs, without modifying routing; this confirms or rules out cross-node token divergence. Compute the drift window as (submission_time - token_issuance_time) and compare it to the configured token lifetime, so you can tell whether the rejection is consistent with expiry or with a separate rotation event. Open a private window, complete the full flow in a single tab, and record whether the rejection reproduces; do not paste tokens, session IDs, or cookies into the command output.