Intermittent redirect to login screen on launch — concurrent refresh-token race (v2.3.0)

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

  1. Log in with “remember me”.

  2. Close the app/all tabs.

  3. After some idle time (can be the same day), open the app fresh (homescreen icon / new tab / cold browser start).

  4. Intermittently, the login screen appears.

  5. 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.