Neither of my two hypotheses was right, and the upstream source was
correct all along. The jwt cookie copied from the browser IS the
browser's session token — same jti — so this server and the tab share
one session, and the tab ends it:
1. nuxt-client sets a purely client-side timer, sessionTimeoutTimestamp
= now + JWT_TIMEOUT_SECONDS, reset only on route change
(watch(router.currentRoute, startTimer)) — never by API activity and
never read back from the server's TTL.
2. AutoLogoutWarning.vue warns at JWT_SHOW_TIMEOUT_WARNING_SECONDS.
3. At zero, autoLogout() -> location.replace('/logout?auto-logout=true').
4. schulcloud-client controllers/login.js:439 -> POST /api/v3/logout
-> removeJwtFromWhitelist(jwt) -> the shared key is deleted.
That explains the endurance failure exactly: the GET pings at t+0/30/60/90
were sliding the Valkey TTL correctly, and then the tab deleted the key.
It also explains the ~1h warning dialog appearing in a tab the user
considers in use — the timer only resets on navigation.
So the sliding TTL is real and a keepalive does hold a session to the
30-day ceiling. The operational fix is not to ping harder but to close
the Schulportal window after copying the cookie; a private window is the
tidy way. This is now the loudest caveat in the token-copying steps,
because it is the single easiest way to break the setup.
Keeping refresh-session rather than reverting to GET, now for a reason
that stands on its own: it states the intent contractually instead of
relying on extend-on-check as a side effect of an unrelated read (that
whitelist has been refactored twice in 2026, and a GET keepalive would
fail silently if it went away), and its budget readout makes session
health visible in the log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>