JWT tutorials tend to stop at jwt.sign() and a 200 OK. Production starts where the tutorial ends: token renewal, session revocation, client-side storage, and the attacks that catch a naive implementation.

Two tokens, two durations

A 15-minute access token limits the exposure window if it leaks. A 7-day refresh token renews the access token without asking for a login again. RFC 6749, section 6, defines that pair as a separate grant:

1. login          -> access token (15 min) + refresh token (7 days)
2. access expires -> client calls /auth/refresh with the cookie
3. refresh expires -> user logs in again

A long access token widens the theft window. Asking for a login every 15 minutes sends users to support. The short and long pair solves both sides.

Generation with separate signatures

import jwt from 'jsonwebtoken'

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET

function generateTokens(user: { id: string; email: string }) {
  const accessToken = jwt.sign(
    { sub: user.id, email: user.email, type: 'access' },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  )

  const refreshToken = jwt.sign(
    { sub: user.id, type: 'refresh' },
    REFRESH_SECRET,
    { expiresIn: '7d' }
  )

  return { accessToken, refreshToken }
}

One shared secret carries a leak from one token to the other. Signing keys come from environment variables and stay out of commits.

RFC 8725, the JWT Best Current Practice, recommends pinning the expected algorithm at verification. Without the pin, the implementation treats the token's alg field as an instruction, and an attacker forges a token with alg: none or swaps HS256 for RS256 with the public key as the secret.

// the algorithm comes from your config, not from the token
const payload = jwt.verify(token, ACCESS_SECRET, {
  algorithms: ['RS256'],
  issuer: 'https://api.example.com',
  audience: 'web-app',
})

Access token in memory, refresh token in a cookie

  • Access token: a variable in JavaScript memory. localStorage persists the token for any XSS payload to read alongside it.
  • Refresh token: a cookie with HttpOnly, Secure, and SameSite=Strict. The MDN Set-Cookie reference describes each flag: HttpOnly removes the cookie from document.cookie, Secure limits sending to HTTPS, SameSite blocks it on cross-site requests.
let accessToken: string | null = null

async function login(email: string, password: string) {
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    credentials: 'include',
    body: JSON.stringify({ email, password }),
  })
  const data = await res.json()
  accessToken = data.accessToken
}

async function apiRequest(url: string, options: RequestInit = {}) {
  const res = await fetch(url, {
    ...options,
    credentials: 'include',
    headers: {
      ...options.headers,
      Authorization: `Bearer ${accessToken}`,
    },
  })

  if (res.status === 401) {
    const renewed = await refreshAccessToken()
    if (renewed) return apiRequest(url, options)
    window.location.href = '/login'
  }

  return res
}

Renewal on 401

async function refreshAccessToken(): Promise<boolean> {
  try {
    const res = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include',
    })

    if (!res.ok) return false

    const data = await res.json()
    accessToken = data.accessToken
    return true
  } catch {
    return false
  }
}

The cookie travels on its own, so the refresh depends on no state in the client. Two simultaneous calls that hit the refresh endpoint at once collide when rotation is on; serialize renewals into a one-slot queue per tab.

Revocation with a denylist and TTL

JWTs check a signature without asking the server, and that property is what blocks automatic revocation. RFC 8725 recommends a denylist for revoked tokens. On logout, store the token with a TTL equal to the time left on its exp:

async function logout(token: string) {
  const decoded = jwt.decode(token) as { exp: number }
  const ttl = decoded.exp - Math.floor(Date.now() / 1000)

  await redis.setex(`blacklist:${token}`, ttl, 'revoked')
}

async function verifyToken(token: string) {
  const blacklisted = await redis.get(`blacklist:${token}`)
  if (blacklisted) throw new Error('Token revoked')

  return jwt.verify(token, ACCESS_SECRET, { algorithms: ['RS256'] })
}

The refresh token needs the same treatment at logout: clear the refresh cookie in the client and invalidate the refresh value on the server. Without that, the refresh outlives the logout and mints access tokens for a session the user ended.

Refresh token rotation

Every refresh use issues a new refresh token and invalidates the previous one. Reusing an already-consumed refresh token signals that someone copied it, and the right response is to drop the user's entire session tree.

The full flow

Login hands out both tokens. The request carries the access token in the header and the refresh cookie. A 401 triggers renewal; when renewal fails, the client sends the user to the login page. Logout clears both sides. The OWASP Authentication Cheat Sheet covers the rest of the list: HTTPS on every route, short expiration, proper password hashing, and attempt limits.

References