Summary
(Created with the assistance of AI)
On launching the web frontend, I’m intermittently dropped to the login screen even though my session is still valid. Closing the tab/app and reopening it loads straight into the app without re-entering credentials. Server logs show this is caused by two POST /api/v1/user/token/refresh requests firing almost simultaneously on launch: the first succeeds and rotates the refresh token, and the second — still carrying the now-rotated token — returns 401 (“refresh token already used”). The frontend reacts to that 401 by redirecting to login, despite the user being authenticated (a GET /api/v1/user immediately afterward returns 200).
This looks like a missing single-flight/lock around the token-refresh call in the web client.
Environment
-
Vikunja version: 2.3.0
-
Deployment: Docker (
vikunja/vikunja:2.3.0), SQLite backend -
Reverse proxy: NGINX Proxy Manager (Websockets Support enabled, Cache Assets off), plain HTTP on a LAN domain
-
Access: via LAN and via Tailscale; reproduced on iOS Safari “Add to Home Screen” web app and on desktop Firefox
-
VIKUNJA_SERVICE_JWTSECRET: explicitly set (persistent across restarts) -
VIKUNJA_SERVICE_JWTTTLSHORT: raised to 86400 (24h) — issue still occurs (see notes)
Steps to reproduce
-
Log in with “remember me”.
-
Close the app/all tabs.
-
After some idle time (can be the same day), open the app fresh (homescreen icon / new tab / cold browser start).
-
Intermittently, the login screen appears.
-
Close and reopen — the app now loads straight in, no credentials required.
Expected behavior
On launch with a valid session, the client refreshes the access token and loads the app. Concurrent refresh attempts should not log the user out.
Actual behavior
A spurious redirect to the login screen on launch, recoverable by reopening. The user is never actually logged out server-side.
Server log evidence
Failed cold launch (timestamps UTC, trimmed; IPs/usernames redacted):
GET / 200
GET /api/v1/info 200
POST /api/v1/user/token/refresh 200 ← first refresh succeeds, rotates token
POST /api/v1/user/token/refresh 401 ← second refresh, ~19ms later, "already used"
GET /api/v1/user 200 ← still authenticated
Immediately following reopen (loads correctly):
GET / 200
POST /api/v1/user/token/refresh 200 ← single refresh, no race
GET /api/v1/labels?page=1 200
GET /api/v1/projects?... 200
GET /api/v1/notifications?page=1 200
GET /api/v1/tasks?... 200
The two refresh requests on the failed launch are ~19ms apart. The difference between a failed and a successful launch is purely how many refresh calls fire concurrently (two vs one) — i.e. it’s timing-dependent, which matches the intermittent behavior.