Stale X-Refreshed-Token replayed from browser HTTP cache (304 on /api/*) logs the user out right after login ("Your session expired")

Author: Sergio-LPACreated Sep 10, 2026Updated Sep 10, 2026

Describe the bug

API JSON responses are sent with Express's default weak ETag and no Cache-Control, so Chrome stores them in its HTTP cache. When a later request revalidates and the server answers 304 Not Modified, the browser hands the app the cached response together with the cached headers. If that cached response was captured while the token was past its half-life, it carries a stale X-Refreshed-Token (server/modules/auth/auth.middleware.ts ~L75-78). The fetch wrapper in src/shared/api.ts unconditionally writes any X-Refreshed-Token it sees back into localStorage, so a freshly issued token is overwritten by a days-old, already-expired one, the client-side expiry check kicks in, the token is removed and the user is bounced to the login screen with "Your session expired. Please log in again." — immediately after a successful login.

The same browser profile keeps failing forever (the cache entry never goes away on its own), while an incognito window works, which is what makes it look like a device problem.

To Reproduce

  1. Log in from Chrome (in my case Chrome 152 on Android, plain http://<host>:3001). Use the UI normally for a few days so that at least one API response (e.g. GET /api/user/onboarding-status) is served while the JWT is past half-life and therefore carries X-Refreshed-Token. Chrome caches that response with its ETag.
  2. Wait until that refreshed token has expired (>7 days), or just let the stored token expire.
  3. Open the UI, log in with valid credentials.
  4. Login succeeds (POST /api/auth/login → 200 with a new token; last_login is updated in auth.db), but the app immediately shows "Your session expired" and returns to the login form.

Packet capture on port 3001 of a failing attempt (Chrome Android, normal profile) vs. a working one (incognito):

# failing (normal profile)
POST /api/auth/login                          -> 200 (new token in body)
GET  /api/user/onboarding-status  auth=Bearer -> 304 Not Modified   <-- cached copy w/ stale X-Refreshed-Token reused
GET  /api/projects                (no Authorization header)  -> 401 X-Auth-Error: invalid-token
GET  /api/providers/sessions/running (no Authorization)      -> 401
GET  /api/auth/user               (no Authorization)         -> 401
# working (incognito, empty cache)
POST /api/auth/login                          -> 200
GET  /api/user/onboarding-status  auth=Bearer -> 200
GET  /api/projects                auth=Bearer -> 200 ... (all fine, /ws upgrades)

Device clock skew vs. server: 0 s. localStorage read/write verified OK on the same origin. Nothing is logged server-side because a missing/expired token only produces a 401 without a console.warn.

Expected behavior

Logging in with valid credentials should keep the session. API responses must not be served from the browser HTTP cache, and a stale X-Refreshed-Token must never be able to replace a newer token.

Suggested fix

Two independent layers, either one is enough to stop the bounce; both together are safest:

  1. Server (server/index.ts, right after app.use(cors({ exposedHeaders: [...] })), ~L123): disable ETags and mark the API as uncacheable:
    typescript
    app.set('etag', false);
    app.use((req, res, next) => {
      if (req.path.startsWith('/api/')) res.setHeader('Cache-Control', 'no-store');
      next();
    });
    (index.html and hashed assets already have explicit Cache-Control; /api/* is the only thing left cacheable.)
  2. Client (src/shared/api.ts): before storing an X-Refreshed-Token, decode it and only accept it if its iat is newer than the currently stored token's iat (and it is not already expired).

I have applied (1) locally on the compiled dist-server/server/index.js and the problem is gone without clearing the browser cache. Happy to open a PR if you prefer.

Desktop / Smartphone

  • Server: CloudCLI 1.37.2 (npm install, installMode: npm), Node 22.23.1, Debian 12 LXC. Code paths cited above are unchanged in 1.37.3 / main.
  • Client that fails: Android 10, Chrome 152 (normal profile). Same Chrome in incognito works. A Windows Chrome 151 profile on the same server has not hit it yet, presumably because it never got a cached response with the header.

Additional context

Related to #754 (token auto-refresh via X-Refreshed-Token): that issue is about refreshes being missed; this one is about a stale refresh header being replayed by the browser cache. The cache-first sw.js is not involved: on plain HTTP (insecure context) navigator.serviceWorker is undefined, so this is the regular HTTP cache.