JWT Refresh Token: Rotation, Revocation, and Secure Storage

A JWT refresh token is a long-lived credential (typically 7–30 days) used to obtain new short-lived access tokens (5–15 minutes) without re-authenticating the user — separating session persistence from resource authorization. The access token's short TTL limits damage if stolen; a Redis blacklist can revoke tokens in O(1) time, with ~50 KB storage per 10,000 revoked entries. This guide covers the access/refresh token lifecycle, rotation strategy (new refresh token on every use), revocation with Redis blacklisting, secure cookie storage to prevent XSS, and client-side silent refresh patterns. To understand the underlying token format, see how JWT works and how to decode a JWT. The payload sections of a JWT use Base64 encoding — understanding that helps when inspecting token contents manually.

Need to inspect a JWT access or refresh token? Paste it into Jsonic's JWT Decoder to see the header, payload, and signature instantly.

Open JWT Decoder →

Access token and refresh token lifecycle

The two-token pattern solves a fundamental tension in stateless authentication: short token lifetimes improve security (a stolen token expires quickly) but degrade user experience (users are forced to re-authenticate frequently). The solution is two tokens with different lifetimes and different jobs.

Access token — a signed JWT, valid for 5–15 minutes, sent in the Authorization: Bearer header on every API request. The server verifies it by checking the signature against the secret or public key; no database lookup is needed. Because verification is stateless, access tokens scale horizontally across microservices without shared session storage.

Refresh token — an opaque string or signed JWT, valid for 7–30 days, stored in an HttpOnly cookie. It is sent only to the dedicated /auth/refresh endpoint, never to API endpoints. The server looks up the refresh token in a database or Redis store, verifies it has not been revoked, and if valid, issues a new access token (and a new refresh token under rotation).

The complete authentication flow has 5 steps: (1) user logs in with credentials → server returns access token (15 min TTL) + refresh token (30 day TTL) in an HttpOnly cookie; (2) client uses access token on API calls; (3) access token expires → client silently calls /auth/refresh; (4) server validates refresh token → issues new access token + new refresh token (rotation); (5) on logout, server revokes the refresh token. At no point does the client handle long-lived credentials in JavaScript-accessible storage. For a deeper look at the token format itself, see how JWT works.

// Step 1: Login — Node.js / Express example
import jwt from 'jsonwebtoken'
import crypto from 'crypto'

const ACCESS_SECRET  = process.env.ACCESS_TOKEN_SECRET   // 32+ random bytes
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET  // different secret

function generateTokens(userId: string) {
  const accessToken = jwt.sign(
    { sub: userId, type: 'access' },
    ACCESS_SECRET,
    { expiresIn: '15m', jwtid: crypto.randomUUID() }  // jti for blacklisting
  )

  const refreshToken = jwt.sign(
    { sub: userId, type: 'refresh', fam: crypto.randomUUID() }, // fam = token family
    REFRESH_SECRET,
    { expiresIn: '30d', jwtid: crypto.randomUUID() }
  )

  return { accessToken, refreshToken }
}

// POST /auth/login
app.post('/auth/login', async (req, res) => {
  const user = await verifyCredentials(req.body.email, req.body.password)
  if (!user) return res.status(401).json({ error: 'Invalid credentials' })

  const { accessToken, refreshToken } = generateTokens(user.id)

  // Persist refresh token (jti → userId, expiry, family)
  await db.refreshTokens.create({
    jti: jwt.decode(refreshToken).jti,
    userId: user.id,
    family: jwt.decode(refreshToken).fam,
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  })

  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 30 * 24 * 60 * 60 * 1000,  // 30 days in ms
    path: '/auth',                        // cookie only sent to /auth routes
  })

  res.json({ accessToken })  // access token to client memory (NOT localStorage)
})

Refresh token rotation strategy

Rotation means issuing a brand-new refresh token on every /auth/refresh call and immediately invalidating the previous one. With a 30-day sliding-window policy, an active user's session never expires — the TTL resets on each use. The critical security property is that a reused (already-rotated) refresh token is a reliable theft signal: it means the token was used by 2 parties, which is only possible if it was stolen.

The token family concept is key to this detection. Every refresh token stores a fam (family) UUID that links all tokens in a session chain back to the original login event. When the server detects a reuse, it invalidates every token in the family — not just the reused token — forcing a full re-login. Without family tracking, an attacker who uses a stolen token before the victim can silently extend the stolen session indefinitely.

// POST /auth/refresh
app.post('/auth/refresh', async (req, res) => {
  const oldRefreshToken = req.cookies.refreshToken
  if (!oldRefreshToken) return res.status(401).json({ error: 'No refresh token' })

  let payload
  try {
    payload = jwt.verify(oldRefreshToken, REFRESH_SECRET)
  } catch {
    return res.status(401).json({ error: 'Invalid refresh token' })
  }

  // Look up the token in the database
  const stored = await db.refreshTokens.findByJti(payload.jti)

  if (!stored) {
    // Token not found — already rotated (reuse detected!)
    // Invalidate the entire token family as a security response
    await db.refreshTokens.revokeFamily(payload.fam)
    return res.status(401).json({ error: 'Token reuse detected — session terminated' })
  }

  if (stored.revokedAt) {
    return res.status(401).json({ error: 'Refresh token revoked' })
  }

  // Mark old token as used (soft-delete preserves lineage for reuse detection)
  await db.refreshTokens.revoke(payload.jti)

  // Issue new token pair (rotation)
  const { accessToken, refreshToken: newRefreshToken } = generateTokens(payload.sub)

  // Store new refresh token in the same family
  await db.refreshTokens.create({
    jti: jwt.decode(newRefreshToken).jti,
    userId: payload.sub,
    family: payload.fam,               // same family UUID
    parentJti: payload.jti,            // lineage for audit trail
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  })

  res.cookie('refreshToken', newRefreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict',
    maxAge: 30 * 24 * 60 * 60 * 1000, path: '/auth',
  })

  res.json({ accessToken })
})

Revoking access tokens with a Redis blacklist

Access tokens are stateless — the server can verify them without any storage. To revoke one before its 15-minute TTL expires (on logout, password change, or detected theft), add its jti (JWT ID) to a Redis blacklist. Every request checks the blacklist in O(1) time with a single Redis GET, adding under 1 ms of latency in co-located deployments.

Storage is minimal. Each blacklisted entry uses approximately 100 bytes (36-byte UUID key + ~60 bytes Redis overhead), so 10,000 revoked tokens consume roughly 50 KB of Redis memory. Redis TTL-based auto-expiry removes entries automatically when the token would have expired anyway, keeping the blacklist size bounded regardless of traffic volume. At 1,000 logouts per day with 15-minute tokens, the blacklist never exceeds ~15,000 live entries at peak — under 1.5 MB.

import { createClient } from 'redis'

const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()

// Add a token to the blacklist on logout or revocation
async function revokeAccessToken(token: string) {
  const payload = jwt.decode(token) as jwt.JwtPayload
  const jti = payload.jti
  const remainingTtlSeconds = payload.exp - Math.floor(Date.now() / 1000)
  if (remainingTtlSeconds > 0) {
    // Store jti with TTL equal to the token's remaining lifetime
    await redis.set(`bl:${jti}`, '1', { EX: remainingTtlSeconds })
  }
}

// Middleware: check blacklist on every protected request
async function requireAuth(req, res, next) {
  const authHeader = req.headers.authorization
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' })
  }
  const token = authHeader.slice(7)

  let payload
  try {
    payload = jwt.verify(token, ACCESS_SECRET) as jwt.JwtPayload
  } catch {
    return res.status(401).json({ error: 'Invalid token' })
  }

  // O(1) blacklist check
  const revoked = await redis.get(`bl:${payload.jti}`)
  if (revoked) {
    return res.status(401).json({ error: 'Token revoked' })
  }

  req.user = { id: payload.sub }
  next()
}

// POST /auth/logout
app.post('/auth/logout', requireAuth, async (req, res) => {
  const token = req.headers.authorization.slice(7)
  await revokeAccessToken(token)

  // Also revoke the refresh token
  const refreshToken = req.cookies.refreshToken
  if (refreshToken) {
    const rfPayload = jwt.decode(refreshToken) as jwt.JwtPayload
    await db.refreshTokens.revoke(rfPayload.jti)
  }

  res.clearCookie('refreshToken', { path: '/auth' })
  res.json({ success: true })
})

Secure cookie storage — blocking XSS and CSRF

Storing a refresh token in an HttpOnly cookie with 3 specific flags blocks the 2 most common web credential theft vectors simultaneously. Each flag targets a different attack class, and all 3 are required for full protection. Using JSON in localStorage is a convenient pattern for application state but is unsafe for long-lived credentials.

Storage locationXSS riskCSRF riskRecommendation
localStorageHigh — readable by any JSLow — not sent automaticallyNever use for refresh tokens
sessionStorageHigh — readable by any JSLow — not sent automaticallyNever use for refresh tokens
JS memory (variable)Medium — lost on page refreshLow — not sent automaticallyUse for access tokens only
HttpOnly cookieNone — JS cannot read itMitigated by SameSite=StrictRecommended for refresh tokens

HttpOnly makes the cookie invisible to document.cookie and all JavaScript APIs. Even if an attacker injects a script via XSS, they cannot read or steal the refresh token. The browser sends it automatically on matching requests but JS never touches it.

Secure ensures the cookie is only transmitted over HTTPS. On HTTP connections (rare in production, common in staging), the browser silently omits the cookie. This prevents network interception on unencrypted connections.

SameSite=Strict prevents the cookie from being sent on any cross-site request — an attacker's page cannot trick the user's browser into sending the refresh token to your server. The path='/auth' attribute further restricts the cookie to only be sent to /auth/* routes, reducing exposure on non-auth endpoints.

// Complete cookie options — copy this for production
res.cookie('refreshToken', refreshToken, {
  httpOnly: true,           // not accessible via document.cookie or JS APIs
  secure: true,             // HTTPS only
  sameSite: 'strict',       // never sent on cross-site requests (CSRF protection)
  path: '/auth',            // only sent to /auth/* routes
  maxAge: 30 * 24 * 60 * 60 * 1000,  // 30 days in milliseconds
  // domain: '.example.com' // uncomment for subdomain sharing
})

Silent token refresh on the client (JavaScript SPA)

Silent refresh keeps the user logged in without interruption by proactively refreshing the access token before it expires. The access token lives in memory (a JavaScript variable), so it is lost on page reload — the silent refresh mechanism re-acquires it automatically using the HttpOnly cookie. 3 components make this work: a refresh timer, a page-load bootstrap call, and an Axios/fetch interceptor for 401 handling.

// auth.ts — client-side token management

let accessToken: string | null = null
let refreshTimer: ReturnType<typeof setTimeout> | null = null
let refreshPromise: Promise<string | null> | null = null

// Decode expiry from JWT without a library (header.payload.sig are Base64url)
function getTokenExpiry(token: string): number {
  const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
  return payload.exp * 1000  // convert to ms
}

// Schedule a proactive refresh 60 seconds before expiry
function scheduleRefresh(token: string) {
  if (refreshTimer) clearTimeout(refreshTimer)
  const expiresAt = getTokenExpiry(token)
  const refreshAt  = expiresAt - 60_000  // 60 s before expiry
  const delay      = Math.max(refreshAt - Date.now(), 0)
  refreshTimer = setTimeout(silentRefresh, delay)
}

// Single in-flight refresh — prevents duplicate /auth/refresh calls
async function silentRefresh(): Promise<string | null> {
  if (refreshPromise) return refreshPromise  // deduplicate concurrent calls

  refreshPromise = fetch('/auth/refresh', {
    method: 'POST',
    credentials: 'include',  // sends the HttpOnly refresh token cookie
  })
    .then(async (res) => {
      if (!res.ok) {
        // Refresh failed — redirect to login
        accessToken = null
        window.location.href = '/login'
        return null
      }
      const data = await res.json()
      accessToken = data.accessToken
      scheduleRefresh(accessToken)
      return accessToken
    })
    .finally(() => { refreshPromise = null })

  return refreshPromise
}

// Bootstrap on page load — re-acquire access token from refresh cookie
export async function initAuth() {
  await silentRefresh()
}

// Axios interceptor — retry once on 401
import axios from 'axios'

axios.interceptors.request.use((config) => {
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`
  }
  return config
})

axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config
    if (error.response?.status === 401 && !originalRequest._retried) {
      originalRequest._retried = true
      const newToken = await silentRefresh()
      if (newToken) {
        originalRequest.headers.Authorization = `Bearer ${newToken}`
        return axios(originalRequest)  // retry original request
      }
    }
    return Promise.reject(error)
  }
)

The refreshPromise deduplication pattern is critical in SPAs: if 3 API calls fail simultaneously with 401 (e.g., on app load with an expired token), all 3 await the same refresh promise rather than firing 3 parallel /auth/refresh requests, which would cause 2 of the 3 rotation events to fail with "token already used."

Key terms: JWT token refresh and revocation glossary

These 6 terms appear throughout JWT authentication documentation and security literature. Understanding the precise meaning of each prevents common implementation mistakes.

Access token
A short-lived signed JWT (5–15 min TTL) sent in the Authorization: Bearer header on API requests. Stateless — verified by signature alone, no database lookup. Lives in JavaScript memory, never in persistent storage.
Refresh token
A long-lived credential (7–30 days) used exclusively to request new access tokens from the /auth/refresh endpoint. Stored in an HttpOnly cookie. Must be tracked server-side (database or Redis) to support revocation.
Token rotation
Issuing a new refresh token on every /auth/refresh call and immediately revoking the old one. Enables theft detection: a reused (already-rotated) token proves 2 parties tried to use the same credential.
Blacklist
A Redis set of revoked token JTIs. Checked on every request in O(1) time. Entries carry a TTL equal to the token's remaining lifetime so they expire automatically. Used primarily for access tokens; refresh tokens use a whitelist (database).
Silent refresh
The client-side pattern of proactively requesting a new access token (using the HttpOnly refresh token cookie) before the current token expires, without user interaction. Implemented via a timer set to fire 60 seconds before token expiry.
Sliding window TTL
A refresh token expiry policy where the TTL resets on every successful use. A 30-day sliding-window token never expires for an active user — they stay logged in indefinitely as long as they use the app at least once every 30 days.

Frequently asked questions

How long should JWT access tokens and refresh tokens live?

Access tokens should live 5–15 minutes. Fifteen minutes is the most common default because it balances usability against security — a stolen access token stops working within 15 minutes at most. High-security apps (banking, healthcare) use 5-minute TTLs. Refresh tokens should live 7–30 days depending on session requirements. Consumer apps often use 30-day sliding-window tokens so active users never get logged out. Enterprise apps typically use 7-day fixed-window tokens. The access token TTL determines the damage window for a stolen credential; the refresh token TTL determines the session lifetime for inactive users. Use how JWT works to understand how these TTLs are encoded in the exp claim.

What is refresh token rotation and why does it detect theft?

Refresh token rotation means the server issues a new refresh token on every /auth/refresh call and immediately invalidates the previous one. The security property: if a token is stolen, both the attacker and the legitimate user hold the same token. Whichever uses it first gets a new one. The other party's next refresh fails with 401. The server detects this reuse — a refresh with an already-used token — and invalidates the entire token family, forcing a full re-login. Without rotation, a stolen refresh token gives the attacker an indefinitely renewable session. This pattern is implemented by Auth0, Okta, and AWS Cognito.

How do I revoke a JWT before it expires without a database?

Use a Redis blacklist. Store the token's jti claim in Redis with a TTL equal to the token's remaining lifetime. On every request, do a Redis GET on the jti — O(1), under 1 ms. If the key exists, reject with 401. Storage costs ~100 bytes per entry; 10,000 revoked tokens use ~50 KB. Redis TTL auto-expiry keeps the blacklist small. For access tokens with a 15-minute TTL, this approach is effectively "without a database" — Redis is an in-memory store, not a persistent database. You can decode a JWT to manually inspect the jti and exp claims during debugging.

Should I store refresh tokens in a cookie or localStorage?

Always use an HttpOnly + Secure + SameSite=Strict cookie. localStorage is readable by any JavaScript on the page — a single XSS vulnerability exfiltrates the token in one line. HttpOnly cookies cannot be read by JavaScript at all. SameSite=Strict prevents CSRF. The access token (15-minute TTL) can live in a JavaScript variable in memory — it is re-acquired from the refresh cookie on page load via silent refresh, so losing it on page reload is acceptable. Never store long-lived credentials in JSON in localStorage — reserve that pattern for non-sensitive application state.

What happens when a stolen refresh token is reused (security response)?

When the server detects that a refresh token has already been used, it means 2 parties tried to use the same token — a reliable theft signal. The correct 3-step response: (1) reject the current request with 401; (2) revoke the entire token family — trace the lineage and invalidate all refresh tokens in the session chain, logging out both the attacker and the legitimate user; (3) trigger a security alert — log the event, email the user about suspicious activity, and flag the account. The user must re-authenticate with their password (and ideally MFA). Without family-level revocation, revoking only the specific reused token is insufficient — the attacker may already hold a child token.

How do I implement silent token refresh on the client side in JavaScript?

Silent refresh has 3 parts: (1) a timer that fires 60 seconds before the access token expires, calling /auth/refresh with credentials: 'include' to send the HttpOnly cookie; (2) a bootstrap call on page load to re-acquire the access token (it was lost when the page refreshed); (3) an Axios/fetch 401 interceptor that retries the original request once after a successful refresh. Use a single in-flight refreshPromise variable to deduplicate concurrent refresh attempts — if 3 API calls fail simultaneously with 401, all 3 should await the same refresh promise, not fire 3 parallel /auth/refresh requests, which would cause token reuse errors under rotation. Libraries like axios-auth-refresh implement this interceptor pattern out of the box. Inspect the token's Base64-encoded payload to verify the exp claim when debugging timer scheduling issues.

Ready to debug your JWT tokens?

Use Jsonic's JWT Decoder to inspect the header, payload, and claims of any access or refresh token. You can also validate JSON payloads returned from your auth endpoints.

Open JWT Decoder →