{"record":{"id":"1dd89ea8b87c9520","repo":"Wei-Shaw/sub2api","slug":"token-refresh-failed","errorCode":"TOKEN_REFRESH_FAILED","errorMessage":"Session expired. Please log in again.","messagePattern":"Session expired\\. Please log in again\\.","errorType":"error_code","errorClass":null,"httpStatus":401,"severity":"error","filePath":"frontend/src/api/client.ts","lineNumber":216,"sourceCode":"                code: 'AUTH_SESSION_CHANGED',\n                message: 'Authentication session changed while refreshing.'\n              })\n            }\n\n            // Clear tokens and redirect to login\n            localStorage.removeItem('auth_token')\n            localStorage.removeItem('refresh_token')\n            localStorage.removeItem('auth_user')\n            localStorage.removeItem('token_expires_at')\n            sessionStorage.setItem('auth_expired', '1')\n\n            if (!window.location.pathname.includes('/login')) {\n              window.location.href = '/login'\n            }\n\n            return Promise.reject({\n              status: 401,\n              code: 'TOKEN_REFRESH_FAILED',\n              message: 'Session expired. Please log in again.'\n            })\n          }\n        }\n\n        // No refresh token or is auth endpoint - clear auth and redirect\n        const hasToken = !!localStorage.getItem('auth_token')\n        const headers = error.config?.headers as Record<string, unknown> | undefined\n        const authHeader = headers?.Authorization ?? headers?.authorization\n        const sentAuth =\n          typeof authHeader === 'string'\n            ? authHeader.trim() !== ''\n            : Array.isArray(authHeader)\n              ? authHeader.length > 0\n              : !!authHeader\n\n        localStorage.removeItem('auth_token')\n        localStorage.removeItem('refresh_token')","sourceCodeStart":198,"sourceCodeEnd":234,"githubUrl":"https://github.com/Wei-Shaw/sub2api/blob/073e92d17178a1ccdb0a27017f572f10c9c7ab62/frontend/src/api/client.ts#L198-L234","documentation":"Thrown by the axios interceptor when the refresh token flow fails and the session has NOT changed (the localStorage refresh_token/auth_user still match the pre-refresh snapshot). Before rejecting, the interceptor clears all auth keys (auth_token, refresh_token, auth_user, token_expires_at), sets sessionStorage 'auth_expired=1', and hard-redirects to /login unless already there. The rejection carries status 401 and code TOKEN_REFRESH_FAILED, meaning the refresh token was absent, invalid, revoked, or the refresh endpoint errored.","triggerScenarios":"A 401 on a protected endpoint with a refresh token that the backend rejects (expired/revoked/rotated elsewhere); a 401 with no refresh token in localStorage while an Authorization header was sent; a 401 on an auth endpoint itself. In all cases the stored session still matches, so the client wipes it and redirects to /login.","commonSituations":"Long-lived SPA left open past refresh-token lifetime; refresh token revoked server-side (password change, admin revoke); clock skew making token_expires_at stale; backend restart losing sessions; another deployment changing auth endpoints.","solutions":["Have the user log in again — the redirect to /login with sessionStorage 'auth_expired=1' is the designed recovery; show a 'session expired' message there.","If this happens immediately after login, verify the login response actually persists refresh_token to localStorage and that the refresh endpoint/URL is correct.","If it happens randomly, check server-side revocation logs and refresh-token rotation: a rotated token reused by a second tab will fail refresh.","Confirm the system clock on client and server is correct (skew can cause premature 401s)."],"exampleFix":"// before\n// app boots, calls API, silently bounces to /login with no explanation\nsessionStorage.removeItem('auth_expired')\n\n// after\n// on the login page, surface the reason\nif (sessionStorage.getItem('auth_expired') === '1') {\n  sessionStorage.removeItem('auth_expired')\n  toast.info('Session expired. Please log in again.')\n}","handlingStrategy":"try-catch","validationCode":null,"typeGuard":"function isTokenRefreshFailed(e: unknown): e is { status: number; code: 'TOKEN_REFRESH_FAILED'; message: string } {\n  return typeof e === 'object' && e !== null && (e as any).code === 'TOKEN_REFRESH_FAILED'\n}","tryCatchPattern":"try {\n  await apiClient.get('/me')\n} catch (e) {\n  if (isTokenRefreshFailed(e)) {\n    // Interceptor already cleared storage and set auth_expired=1 + redirect.\n    // Surface a friendly message; do not retry with the wiped credentials.\n    toast.info('Session expired. Please log in again.')\n    return\n  }\n  throw e\n}","preventionTips":["Keep the app's tab active or implement a proactive refresh timer before token_expires_at elapses.","On login, verify refresh_token is actually persisted to localStorage before navigating away.","Handle refresh-token rotation: never reuse an already-consumed refresh token from a second tab.","Check sessionStorage 'auth_expired' on the login page to explain why the user was redirected."],"tags":["auth","frontend","axios","session-expiry","redirect"],"backgroundTag":null,"analyzedSha":"073e92d17178a1ccdb0a27017f572f10c9c7ab62","analyzedAt":"2026-08-15T14:33:00.750Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}