Wei-Shaw/sub2api · error

TOKEN_REFRESH_FAILED

TOKEN_REFRESH_FAILED

Error message

Session expired. Please log in again.

What it means

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.

Source

Thrown at frontend/src/api/client.ts:216

                code: 'AUTH_SESSION_CHANGED',
                message: 'Authentication session changed while refreshing.'
              })
            }

            // Clear tokens and redirect to login
            localStorage.removeItem('auth_token')
            localStorage.removeItem('refresh_token')
            localStorage.removeItem('auth_user')
            localStorage.removeItem('token_expires_at')
            sessionStorage.setItem('auth_expired', '1')

            if (!window.location.pathname.includes('/login')) {
              window.location.href = '/login'
            }

            return Promise.reject({
              status: 401,
              code: 'TOKEN_REFRESH_FAILED',
              message: 'Session expired. Please log in again.'
            })
          }
        }

        // No refresh token or is auth endpoint - clear auth and redirect
        const hasToken = !!localStorage.getItem('auth_token')
        const headers = error.config?.headers as Record<string, unknown> | undefined
        const authHeader = headers?.Authorization ?? headers?.authorization
        const sentAuth =
          typeof authHeader === 'string'
            ? authHeader.trim() !== ''
            : Array.isArray(authHeader)
              ? authHeader.length > 0
              : !!authHeader

        localStorage.removeItem('auth_token')
        localStorage.removeItem('refresh_token')

View on GitHub (pinned to 073e92d171)

Solutions

  1. 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.
  2. If this happens immediately after login, verify the login response actually persists refresh_token to localStorage and that the refresh endpoint/URL is correct.
  3. If it happens randomly, check server-side revocation logs and refresh-token rotation: a rotated token reused by a second tab will fail refresh.
  4. Confirm the system clock on client and server is correct (skew can cause premature 401s).

Example fix

// before
// app boots, calls API, silently bounces to /login with no explanation
sessionStorage.removeItem('auth_expired')

// after
// on the login page, surface the reason
if (sessionStorage.getItem('auth_expired') === '1') {
  sessionStorage.removeItem('auth_expired')
  toast.info('Session expired. Please log in again.')
}
Defensive patterns

Strategy: try-catch

Type guard

function isTokenRefreshFailed(e: unknown): e is { status: number; code: 'TOKEN_REFRESH_FAILED'; message: string } {
  return typeof e === 'object' && e !== null && (e as any).code === 'TOKEN_REFRESH_FAILED'
}

Try / catch

try {
  await apiClient.get('/me')
} catch (e) {
  if (isTokenRefreshFailed(e)) {
    // Interceptor already cleared storage and set auth_expired=1 + redirect.
    // Surface a friendly message; do not retry with the wiped credentials.
    toast.info('Session expired. Please log in again.')
    return
  }
  throw e
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of Wei-Shaw/sub2api@073e92d171 (2026-08-15). Data as JSON: /api/errors/1dd89ea8b87c9520. Report an issue: GitHub.