How to handle JWT expiration and token refresh

Why JWTs expire, what the exp claim really is, and how the access-token + refresh-token pattern keeps users logged in without long-lived credentials.

· 8min read

A JWT that never expires is a security liability. But a JWT that expires too aggressively logs your users out mid-task. Every real authentication system has to sit somewhere between those two, and the mechanism that makes it work is token refresh. Here's how expiration actually works and how to handle it without frustrating your users.

What exp actually is

Every well-formed JWT carries an exp claim in its payload - the moment the token stops being valid, stored as a Unix timestamp (seconds since 1970):

{ "sub": "u_42", "role": "admin", "exp": 1782000000 }

Verification isn't optional here: a correct server rejects any token whose exp is in the past, automatically. You can see this for yourself - paste a token into the JWT decoder and it converts exp to a readable date and tells you whether the token is still valid or already expired.

Why not just make tokens long-lived?

It's tempting to set exp a month out and forget about it. Don't. A JWT is stateless - there's no server-side record to delete - so once issued, it stays valid until it expires no matter what. If a long-lived token leaks, an attacker holds a working credential for a month. Short expiry is your main damage-control lever: the sooner a stolen token dies, the smaller the window of abuse.

So you want short expiry for safety and long sessions for usability. Those pull in opposite directions. The refresh pattern resolves the conflict.

The access + refresh token pattern

Split authentication into two tokens with different jobs:

Access tokenRefresh token
LifetimeShort (5-15 min)Long (days-weeks)
Used forEvery API requestOnly to get a new access token
Where it livesIn memoryHttpOnly cookie
If stolenExpires fastCan be revoked server-side

The access token does the work and dies quickly. The refresh token sits safely aside and is only ever sent to one endpoint - /refresh - to mint a fresh access token when the old one expires.

The refresh flow, step by step

  1. User logs in -> server returns a short-lived access token and a refresh token.
  2. The client calls your API with the access token until it expires.
  3. A request comes back 401 Unauthorized - the access token has expired.
  4. The client silently calls /refresh with the refresh token.
  5. The server validates the refresh token and issues a new access token.
  6. The client retries the original request. The user notices nothing.
async function apiFetch(url, options) {
  let res = await fetch(url, withAuth(options))
  if (res.status === 401) {
    await refreshAccessToken()   // hit /refresh, store new token
    res = await fetch(url, withAuth(options))  // retry once
  }
  return res
}

That single "on 401, refresh and retry once" wrapper is the heart of most client auth layers.

Refreshing before it breaks

Reacting to a 401 works, but you can also refresh proactively. Because you can read the exp claim on the client, you know exactly when the access token dies and can refresh a little before - say, 60 seconds out - so no request ever fails. Decoding the token to read exp is a client-side operation (no server round-trip), which is exactly what a browser JWT decoder does under the hood.

Common pitfalls

  • Refresh token rotation. Issue a new refresh token every time one is used and invalidate the old one. If an attacker replays a stolen refresh token, the rotation breaks and you can detect the theft.
  • Storing the refresh token safely. It's the long-lived, high-value credential - keep it in an HttpOnly cookie, never in localStorage. See where to store a JWT.
  • Handling refresh failure. If /refresh itself returns 401, the refresh token is dead too - send the user back to login rather than looping.
  • Don't trust client clocks for security. Reading exp on the client is fine for scheduling a refresh; the real expiry check must always happen on the server.

The honest summary

Short-lived access tokens plus a revocable refresh token give you the safety of quick expiry and the comfort of a long session. Handle the 401 -> refresh -> retry cycle in one place, rotate refresh tokens, and store them in an HttpOnly cookie. And whenever a token behaves oddly, the first debugging step is to read its exp - decode it in the in-browser JWT decoder to see precisely when it expires, without sending a live credential anywhere.