JWT Refresh Token Rotation Done Right

Short-lived access tokens plus long-lived refresh tokens solve the JWT expiry dilemma — but only with rotation. This guide explains the two-token model, how rotation detects stolen tokens, where to store each token, and the silent-refresh pattern users expect.

Written by Projectech7 min readPublished
For B.E./B.Tech Computer Science and IT students who have implemented basic JWT login and need secure, long-lived sessions without making users log in repeatedly Topics: JWT, Authentication, Node.js, Web Security
Illustration of JWT refresh token rotation showing an access token and refresh token pair being exchanged for a fresh pair in a cycle.
Illustration generated for this guide.
In this guide

You built login with JWT. The user logs in, gets a token, and your frontend attaches it to every request. It works — until you ask the obvious question: how long should the token live?

Make it live for 30 days and a stolen token gives an attacker a month of access. Make it live for 15 minutes and your users get logged out constantly. The industry answer is two tokens with different jobs — and a rotation pattern that limits the damage of theft. This guide explains it properly.

The two-token model

Access token Refresh token
Lifetime Short (5–15 minutes) Long (days to weeks)
Where it goes Every API request (Authorization header) Only to the token-refresh endpoint
What it proves "This request is authenticated" "Issue me a new access token"
Stored where Memory (preferred) or httpOnly cookie httpOnly, Secure, SameSite cookie

The logic: the access token is exposed constantly (every request), so it must die quickly. The refresh token is used rarely (once per 15 minutes), so it can live longer — but it needs stronger protection because it is the more powerful credential.

What a JWT actually contains

A JWT is three Base64 parts joined by dots: header.payload.signature. The payload is just JSON claims:

{
  "sub": "user_42",
  "iat": 1727085600,
  "exp": 1727086500,
  "type": "access"
}

sub is the subject (who), iat issued-at, exp expiry, and a type claim distinguishing access from refresh tokens. The signature lets your server verify the token was not tampered with — without a database lookup, which is the whole performance point of JWTs.

Note: JWT payloads are Base64-encoded, not encrypted. Anyone holding the token can read the claims. Never put sensitive data (passwords, internal IDs you want hidden) in a JWT payload.

The rotation pattern, step by step

"Rotation" means every refresh consumes the old refresh token and issues a brand-new pair. Here is the flow:

  1. Login — user provides credentials; server returns an access token (15 min) and a refresh token (7 days) in an httpOnly cookie.
  2. Normal requests — frontend sends the access token; server verifies the signature and expiry. No database hit.
  3. Access token expires — frontend calls POST /auth/refresh (the cookie goes automatically); server validates the refresh token.
  4. Rotation — server issues a NEW access token AND a NEW refresh token, and invalidates the old refresh token.
  5. Repeat — the client now holds the new pair; the old refresh token can never be used again.

Why rotate instead of reusing one long-lived refresh token? Theft detection. If an attacker steals the refresh token and uses it, they get a new pair — and the legitimate user's next refresh attempt fails because their (now old) token was already consumed. That failure is a signal: someone else is using this session. The server can then revoke the whole token family and force a re-login.

Refresh token reuse detection

This is the key security property. Implementation sketch:

  • Server keeps a small record per refresh token: token ID, user ID, and whether it has been used.
  • On refresh: if the presented token was already consumed → treat as suspected theft → revoke all tokens for that user → require fresh login.
  • If valid and unused → mark it consumed, issue a new pair.

You do need some server-side storage for refresh tokens (or at least their IDs) — this is the accepted trade-off. Access tokens stay fully stateless; refresh tokens get a lightweight allowlist.

Where to store tokens (the decision that matters most)

Storage XSS risk CSRF risk Verdict
localStorage High — any injected script reads it None Avoid for tokens
httpOnly cookie Low — JS cannot read it Needs SameSite + anti-CSRF Recommended for refresh tokens
In-memory (JS variable) Low — gone on page reload None Good for access tokens in SPAs

The widely recommended student-friendly setup:

  • Access token in memory — a JS variable. Survives navigation, dies on reload (then you silently refresh on app load). Immune to both XSS theft-by-reading and CSRF.
  • Refresh token in httpOnly, Secure, SameSite=Strict cookie — JS cannot touch it, the browser sends it only to your domain, and only over HTTPS.

Note: SameSite cookies handle most CSRF, but defense in depth still suggests anti-CSRF tokens for state-changing endpoints if your threat model needs it. The OWASP Top 10 guide covers the attack side.

The silent-refresh UX pattern

Users should never see "session expired" if their refresh token is valid. The standard SPA pattern:

  • An HTTP client wrapper (e.g. an Axios interceptor) catches 401 responses.
  • On 401, it calls /auth/refresh, retries the original request with the new access token, all invisible to the user.
  • If refresh also fails → redirect to login.
  • Guard against parallel refreshes: if five requests 401 at once, only one refresh call should fire; the others wait for it.

Logout done right

Logout is not just "delete tokens on the client." A complete logout:

  1. Client discards the access token from memory.
  2. Client calls POST /auth/logout.
  3. Server invalidates the refresh token (removes it from the allowlist) and clears the cookie.
  4. For "log out everywhere," invalidate all refresh tokens for that user.

Without step 3, a stolen refresh token keeps working after the user "logged out" — a real and common bug.

Sliding sessions vs absolute limits

Rotation gives you sliding expiration: every refresh extends the session, so an active user stays logged in indefinitely. That is usually what you want — but consider two refinements:

  • Absolute session lifetime. Even with rotation, cap the total session (e.g. 30 days), after which the user must re-enter credentials. This bounds the damage of a stolen refresh token that the legitimate user never notices.
  • Sensitivity-based lifetimes. A banking app might use 5-minute access tokens and 12-hour refresh tokens; a forum can live with 1-hour access and 30-day refresh. Match token lifetimes to what is at stake, not to a blog post's defaults.
  • "Remember me" as a real choice. Unchecked: short refresh lifetime (a day) tied to the browser session. Checked: long refresh lifetime (weeks) in a persistent cookie. Two code paths, meaningfully different security postures — implement both deliberately instead of one accidental middle ground.

Common mistakes

  • One long-lived JWT for everything. A 30-day access token in localStorage is the worst of all worlds: constantly exposed, rarely expiring, readable by any XSS payload.
  • No rotation. Reusing a single refresh token forever means theft is invisible — attacker and user share the session indefinitely.
  • Storing refresh tokens in localStorage. The most stolen credential in the least protected place.
  • Forgetting reuse detection. Rotation without tracking consumed tokens gives you new tokens but no theft signal.
  • Refresh endpoint without rate limiting. It is a prime brute-force target; rate-limit it like a login endpoint. See Rate Limiting APIs: Strategies Explained.
  • Putting the kitchen sink in JWT claims. Keep payloads small — the token travels on every request.

Quick checklist

  • Access tokens: 5–15 minute expiry, type: access claim
  • Refresh tokens: httpOnly + Secure + SameSite cookie, server-side allowlist
  • Rotation on every refresh, with reuse detection that revokes the family
  • Silent refresh in the frontend HTTP layer with single-flight refresh
  • Logout invalidates server-side, not just client-side
  • Refresh and login endpoints rate-limited

Where to go from here

More project guides

More in Web Development