Skip to content

Stale CSRF token after remember-me session renewal — every CP save 419s until hard refresh (regression of #10793) #15097

Description

@jeffcj

Bug description

When the session expires while a CP tab is idle and the user was logged in with remember me, the next request is silently re-authenticated by Laravel's recaller into a new session with a new CSRF token. The v6 CP (SPA) keeps its bootstrap token in memory (Statamic.$config.get('csrfToken')) and never refreshes it, so every subsequent POST/PATCH fails with 419 — saves, POST /cp/preferences/js, actions/list, previews — while all GETs keep working and the user appears fully logged in.

Because the CP otherwise works, users don't think to refresh; and since browser tab-sleep/restore preserves the JS heap, we've observed a user stuck in this state across multiple days, retrying saves and getting "invalid CSRF token" every time.

This is the same underlying problem as #10793, which was fixed for the v5 CP in #10794 by refreshing the token when the session-expiry login modal closes. The v6 CP rewrite doesn't take that path, so the fix is effectively regressed:

  • Statamic\Http\Controllers\CP\SessionTimeoutController explicitly anticipates the remember-me re-auth case (falls back to now() for last_activity) and returns a full fresh countdown — so the CP concludes the session is healthy and never shows a re-login modal.
  • The CP has an updateCsrfToken() helper (fetches cp_url('auth/token') and updates both the axios default header and $config.csrfToken), but nothing invokes it on this silent-renewal path.

How to reproduce

  1. Enable remember me (we hit this via OAuth with statamic.oauth.remember_me: true, but the plain login checkbox should behave the same).
  2. Log into the CP, keep the tab open (or let the browser sleep it) and stay idle past session.lifetime so the session is garbage-collected (easy to force with redis sessions by deleting the session key).
  3. Return to the tab and navigate around — GETs work, you're "logged in" (recaller re-auth).
  4. Try to save anything → 419 CSRF token mismatch, on every attempt, until a hard browser refresh.

Real-world log signature from our production site (times CDT; session lifetime 120 min):

11:57  POST /cp/.../preview            200   ← token fresh
        (~4h idle; session TTL'd out of redis)
15:51  GET  /cp/session-timeout        200   ← tab notices, countdown resets, no modal
16:29  GET  /cp/collections/...        200   ← recaller re-auth, new session+token
16:31  POST /cp/preferences/js         419
16:31  POST /cp/.../actions/list       419   ← every write 419s from here on,
...                                            including the whole next day

Suggested fix

Call updateCsrfToken() whenever the silent renewal can have happened — e.g. after the session-timeout ping when the returned remaining time increased (renewal happened server-side), and/or on window focus / visibilitychange after an expiry was observed. As a downstream workaround we've added a focus listener that re-fetches cp_url('auth/token') and updates $axios.defaults.headers.common['X-CSRF-TOKEN'] + $config.set('csrfToken', ...), which resolves it.

Environment

  • Statamic 6.20.0 Pro
  • Laravel 12, PHP 8.3
  • SESSION_DRIVER=redis, session.lifetime 120
  • Auth via OAuth (Keycloak) with remember_me: true; users repository: file
  • Reproduced in Chrome 150 (Windows) — not browser-specific

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions