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
- Enable remember me (we hit this via OAuth with
statamic.oauth.remember_me: true, but the plain login checkbox should behave the same).
- 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).
- Return to the tab and navigate around — GETs work, you're "logged in" (recaller re-auth).
- 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
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\SessionTimeoutControllerexplicitly anticipates the remember-me re-auth case (falls back tonow()forlast_activity) and returns a full fresh countdown — so the CP concludes the session is healthy and never shows a re-login modal.updateCsrfToken()helper (fetchescp_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
statamic.oauth.remember_me: true, but the plain login checkbox should behave the same).session.lifetimeso the session is garbage-collected (easy to force with redis sessions by deleting the session key).Real-world log signature from our production site (times CDT; session lifetime 120 min):
Suggested fix
Call
updateCsrfToken()whenever the silent renewal can have happened — e.g. after thesession-timeoutping when the returned remaining time increased (renewal happened server-side), and/or onwindowfocus /visibilitychangeafter an expiry was observed. As a downstream workaround we've added a focus listener that re-fetchescp_url('auth/token')and updates$axios.defaults.headers.common['X-CSRF-TOKEN']+$config.set('csrfToken', ...), which resolves it.Environment
SESSION_DRIVER=redis,session.lifetime120remember_me: true; users repository: file