Authentication Token Expiration Issues
How to detect, handle, and refresh expired tokens safely — patterns for clients and servers.
Why tokens expire
Short-lived tokens (access tokens) reduce blast radius if a secret is leaked. Implementing a refresh flow provides a balance between security and usability.
Common token types:
- Access token: short-lived (minutes–hours), used for API calls.
- Refresh token: long-lived, used only to obtain new access tokens.
- Short-lived server tokens: used for server-to-server and rotated regularly.
Client-side handling patterns
Best-practice approaches for browser / mobile clients:
- Use refresh tokens via your backend: do not store long-lived refresh tokens in browser JS when possible.
- Silent refresh: perform token refresh in background before access token expiry (e.g., when token has 60s left).
- Retry once on 401: on API 401/403 due to expired token, attempt refresh then replay the original request (idempotent ops only).
- Exponential backoff: when refresh fails (network/server), back off and surface an error to the user after repeated failures.
Client example (axios) — refresh on 401
// Pseudo-code
axios.interceptors.response.use(null, async (error) => {
const status = error.response?.status
if (status === 401 && !error.config.__isRetryRequest) {
error.config.__isRetryRequest = true
await auth.refreshToken() // call backend endpoint to refresh session
return axios(error.config) // retry original request
}
throw error
})Server-side / API considerations
Servers should:
- Validate token expiry and signatures on each request.
- Return clear error codes and bodies for expired tokens (401 with a reason or structured JSON).
- Offer a protected refresh endpoint that accepts a refresh token and returns a new access token (and optionally a rotated refresh token).
- Support refresh token rotation to reduce misuse risk: issue a new refresh token on each refresh and invalidate the previous one.
Server refresh endpoint (pseudo)
POST /auth/refresh
Body: { refresh_token: "rt_..." }
Response: { access_token: "at_...", expires_in: 3600, refresh_token: "rt_new_..." }Refresh token rotation & revocation
Rotation improves security: each time a refresh token is used, the server issues a new refresh token and invalidates the old one. Detect reuse of old refresh tokens (possible theft) and revoke the entire session if detected.
- Store rotation metadata server-side (last issued token ID, client fingerprint).
- If an old refresh token is presented after rotation, treat it as compromised — revoke session and require re-authentication.
Rotation flow (concept)
1) Client sends refresh_token RT1 -> server verifies and issues AT2 + RT2 2) Server marks RT1 as rotated (cannot be used again) 3) If any request later uses RT1 -> server flags possible compromise and revokes session
Secure storage & transport
- Always transport tokens over TLS (HTTPS).
- Server-to-server: store secrets in environment variables or a secrets manager.
- Browsers: prefer httpOnly, Secure cookies for refresh tokens to mitigate XSS exposure.
- Mobile: use secure storage (Keychain / Keystore) for tokens and protect refresh tokens with platform capabilities.
Error handling & UX
How to respond when refresh fails:
- If refresh fails due to invalid/expired refresh token → force full re-authentication (redirect to login).
- If refresh fails due to transient error (network/5xx) → retry with backoff; after N attempts, show an error and allow retry.
- Provide clear UI messaging (e.g., "Session expired — signing you in again" vs "Connection error — try again").
Example flows & snippets
1) Browser: silent refresh with httpOnly cookie
// client: call protected API
fetch('/api/protected', { credentials: 'include' })
.then(res => {
if (res.status === 401) {
// attempt silent refresh endpoint which uses httpOnly refresh cookie
return fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
.then(r => {
if (!r.ok) throw new Error('refresh failed')
return fetch('/api/protected', { credentials: 'include' })
})
}
return res
})2) Server: refresh endpoint (Node/Express pseudo)
app.post('/auth/refresh', async (req, res) => {
const rt = req.cookies['refresh_token'] // httpOnly cookie
if (!rt) return res.status(401).json({ error: 'no refresh token' })
const session = await db.findSessionByRefreshToken(rt)
if (!session) return res.status(401).json({ error: 'invalid refresh token' })
// issue new access token
const accessToken = signAccessToken({ userId: session.userId })
// optionally rotate refresh token
const newRefreshToken = createRefreshToken()
await db.replaceRefreshToken(session.id, rt, newRefreshToken)
res.cookie('refresh_token', newRefreshToken, { httpOnly: true, secure: true })
res.json({ access_token: accessToken, expires_in: 3600 })
})3) Mobile: secure storage & refresh
// store refresh token in Keychain/Keystore // on API 401: await auth.refreshViaBackend() // POST /auth/refresh with device auth // if refresh successful: retry original request, else prompt login
Operational & security recommendations
- Rotate refresh tokens regularly and support server-side revocation of tokens and sessions.
- Use device fingerprints, IP heuristics, and anomaly detection to identify suspicious refresh attempts.
- Limit refresh token lifetime and apply sliding expiration as needed (shorten when suspicious activity detected).
- Log refresh attempts, successes, and failures for audit and incident response.
Was this page helpful?
Your feedback helps us improve RunAsh docs.