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.