JWT Expired Errors in Production: Refresh Tokens Done Right

'jwt expired' and random 401 logouts in production? Fix clock skew, concurrent refresh races, and silent token expiry with a single-flight refresh pattern.

"TokenExpiredError: jwt expired" is normal; what breaks users in production is how the client reacts to it, such as several parallel requests refreshing the token at once, a refresh token that rotates and invalidates itself, or server clocks that disagree by a few seconds.

Handle expiry on purpose and users never notice it. Handle it badly and they get logged out in the middle of a checkout.

The Errors You Will See

TokenExpiredError: jwt expired
JsonWebTokenError: invalid signature
NotBeforeError: jwt not active
Error Meaning Usual cause
jwt expired exp claim is in the past Normal expiry, or clock skew
invalid signature Signed with a different secret or key Secret rotated, wrong env var per environment
jwt not active nbf is in the future Server clock behind the issuer
jwt malformed Not a valid token string Truncated header, Bearer prefix issue

Send expired and invalid tokens back as 401, never as 500. A malformed token is a client error; return it with a clean body as shown in Next.js API routes error handling.

Fix 1: Allow Small Clock Skew

Servers drift. A token issued by server A can look "not yet valid" or "just expired" on server B.

import jwt from 'jsonwebtoken'

try {
  const payload = jwt.verify(token, process.env.JWT_SECRET!, { clockTolerance: 5 }) // seconds
} catch (e: any) {
  if (e.name === 'TokenExpiredError') return unauthorized('token_expired')
  return unauthorized('invalid_token')
}

Keep tolerance small (5 to 30 seconds) and run NTP on every host. Return a distinct code such as token_expired so the client knows to refresh rather than log out.

Fix 2: Single-Flight Refresh on the Client

When a page fires five requests and the access token has just expired, all five get 401 and all five try to refresh. If refresh tokens rotate, only the first succeeds, and the rest invalidate the session. Share one in-flight refresh:

let refreshing: Promise<string> | null = null

async function getFreshToken(): Promise<string> {
  refreshing ??= fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
    .then(r => {
      if (!r.ok) throw new Error('refresh_failed')
      return r.json()
    })
    .then(d => d.accessToken)
    .finally(() => { refreshing = null })
  return refreshing
}

export async function apiFetch(url: string, init: RequestInit = {}) {
  let res = await fetch(url, withAuth(init, await currentToken()))
  if (res.status === 401) {
    const token = await getFreshToken()
    res = await fetch(url, withAuth(init, token)) // retry once
  }
  return res
}

Retry only once. If it still fails, send the user to login with a clear message. When the same logic runs through a data library, centralize it as shown in TanStack Query error handling.

Fix 3: Choose Sensible Lifetimes and Storage

Token Lifetime Storage
Access Short (5 to 15 minutes) Memory
Refresh Days to weeks, rotate on use HttpOnly, Secure, SameSite cookie

Avoid storing long-lived tokens in localStorage, where any script injection can read them. On refresh-token rotation, handle reuse detection deliberately: a reused old token should revoke the session family, but a double-click should not.

Fix 4: Do Not Log Tokens

Log the token ID (jti), user ID, and error type. Never log the token string itself.

Fix 5: Match Secrets Across Environments

invalid signature right after a deploy usually means the secret or key differs between instances or environments. Verify the variable is set everywhere, as covered in env vars undefined in production. Validate decoded claims with a schema, as in the Zod validation guide, rather than trusting payload.userId is a string.

Tell Real Logouts From Expiry Spikes

A rise in 401s right after a release means you changed something in auth. Track 401 rate separately from 5xx, and alert on a jump. A user-facing "logged out for no reason" bug rarely creates an exception, so error trackers stay quiet.

Let Relia Catch Silent Auth Failures

Relia links the user's session to the failing request, so a wave of 401s shows up as an incident with the route and the likely cause. On request it writes a fix plan with a "Copy for your AI assistant" button; Relia doesn't change code itself, and after you ship it checks the fix against live traffic.

FAQ

Why do I get "jwt expired" right after login?

Usually clock skew between servers, or an expiresIn value set in the wrong unit. Check iat, exp, and server time.

Should I return 401 or 403 for an expired token?

401. The user is not authenticated. Use 403 when the user is authenticated but not allowed.

How do I avoid logging users out when tokens expire?

Refresh silently with a single in-flight refresh, retry the failed request once, and only redirect to login if the refresh itself fails.

All guides