Most JWT tutorials end with jwt.sign() and a 200 OK. Production starts where the tutorial ends: how to renew tokens, how to invalidate sessions, how to store tokens on the client, and how to protect against attacks.

Why JWT + Refresh Token

Short access tokens (15 min) limit the exposure window if a token is compromised. Long refresh tokens (7 days) allow renewing the access token without asking for login again. The cycle:

1. User logs in → receives access token (15min) + refresh token (7 days)
2. Access token expires → client uses refresh token to renew
3. Refresh token expires → user logs in again

Without refresh tokens, you need long access tokens (dangerous) or ask for login every 15 minutes (bad for experience).

Token generation

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 }
}

Two different secrets. If the refresh secret leaks, the access token isn't affected. Never use the same secret for both.

Secure storage on the client

Client-side access token storage is debated, but the safe practice is:

  • Access token: JavaScript memory (variable). Not localStorage (vulnerable to XSS), not HttpOnly cookie (shares between tabs, but works for many cases).
  • Refresh token: HttpOnly cookie, Secure, SameSite=Strict. JavaScript should never access the refresh token.
// On login, store access token in memory
let accessToken: string | null = null

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

// On each request, use the access token
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) {
    // Access token expired: try to refresh
    const renewed = await refreshAccessToken()
    if (renewed) {
      return apiRequest(url, options)  // try again
    }
    window.location.href = '/login'
  }

  return res
}

Refresh token flow

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

    if (!res.ok) return false

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

Token invalidation

JWT by itself doesn't support invalidation (it's stateless). To invalidate sessions, maintain a list of revoked tokens on the server:

// On logout, store the token in the blacklist
async function logout(token: string) {
  const decoded = jwt.decode(token)
  const ttl = decoded.exp - Math.floor(Date.now() / 1000)

  // Store in blacklist with TTL equal to token remaining time
  await redis.setex(`blacklist:${token}`, ttl, 'revoked')
}

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

  return jwt.verify(token, ACCESS_SECRET)
}

Mandatory protections

  • HTTPS on all routes. No exception. Tokens over HTTP are intercepted.
  • HttpOnly + Secure + SameSite on refresh cookies.
  • Never store tokens in localStorage. Vulnerable to XSS.
  • Validate iss, aud, and exp on every request. Don't trust the signature alone.
  • Use strong algorithm (RS256) in production. HS256 works but is less secure for distributed systems.

The complete flow

Login → access + refresh tokens. Request with access token. If 401, try refresh. If refresh fails, redirect to login. Logout invalidates both tokens. Refresh tokens run in background before expiring.

This is the minimum for JWT authentication in production. Anything below this is a prototype, not an authentication system.