• Fri, Mar 2026

Add JWT Rotation & Refresh Token Revocation to Your Vue 2FA Flow (Real-World)

Add JWT Rotation & Refresh Token Revocation to Your Vue 2FA Flow (Real-World)

Nice choice — Combining Two-Factor Authentication (2FA) with robust refresh-token rotation and revocation makes your app resilient to both credential theft and stolen long-lived tokens. In this tutorial I’ll extend the previous Vue + Node 2FA example to a production-minded flow: short-lived JWT access tokens, rotating refresh tokens stored as HttpOnly cookies, server-side refresh token state in Redis, reuse detection and revocation, and the Vue client logic (axios interceptor) to handle silent refreshes. This is a practical blueprint you can adapt to your app.

High-level goals & threat model

Goal: Issue short-lived access tokens (JWT) used for API calls, and rotating refresh tokens (HttpOnly cookies) that are replaced on each refresh. Detect reuse of old refresh tokens (replay) and revoke sessions when suspicious activity is detected.

Threats addressed:

  • Stolen access tokens — short lifetime limits impact.
  • Stolen refresh tokens — rotation and reuse detection limit attacker window and allow revocation.
  • Compromised credentials — 2FA makes initial authentication harder for attackers.

Design overview

  1. User authenticates with username/password. If 2FA enabled, server requests OTP. After successful 2FA verification, server issues an access token (JWT, short-lived) and a refresh token (longer-lived) and stores refresh token state server-side.
  2. Client stores access token in memory (not localStorage) and a refresh token is set by server as an HttpOnly Secure cookie.
  3. When access token expires (401), client calls /auth/refresh. Server validates refresh token using server-side state, rotates it (issues new refresh token & cookie), and returns a new access token.
  4. If a refresh token is used that is missing/doesn't match the stored hash (reuse), server treats it as theft: revoke all sessions for that user and require re-login.

Backend: Node.js + Express + Redis — full example

The following backend builds on previous examples but adds:

  • Separate JWT secrets for access and refresh tokens.
  • Refresh token rotation and server-side state (Redis).
  • Integration with 2FA: issue tokens only after 2FA verified.
  • Reuse detection and session revocation.

Install dependencies

npm init -y
npm install express cookie-parser dotenv jsonwebtoken ioredis uuid bcryptjs speakeasy qrcode

.env (example)

PORT=4000
JWT_ACCESS_SECRET=your_access_secret_here
JWT_REFRESH_SECRET=your_refresh_secret_here
ACCESS_TOKEN_EXP=15m
REFRESH_TOKEN_EXP=14d
REDIS_URL=redis://localhost:6379
TOKEN_PEPPER=some-secret-pepper
NODE_ENV=development

tokens.js — helper utils

// tokens.js
const jwt = require('jsonwebtoken')
const crypto = require('crypto')
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET
const ACCESS_EXP = process.env.ACCESS_TOKEN_EXP || '15m'
const REFRESH_EXP = process.env.REFRESH_TOKEN_EXP || '14d'
const PEPPER = process.env.TOKEN_PEPPER || 'pepper'
function signAccessToken(payload) {
  return jwt.sign(payload, ACCESS_SECRET, { expiresIn: ACCESS_EXP })
}
function signRefreshToken(payload) {
  return jwt.sign(payload, REFRESH_SECRET, { expiresIn: REFRESH_EXP })
}
function verifyRefreshToken(token) {
  return jwt.verify(token, REFRESH_SECRET)
}
function hashToken(token) {
  const h = crypto.createHmac('sha256', PEPPER)
  h.update(token)
  return h.digest('hex')
}
module.exports = { signAccessToken, signRefreshToken, verifyRefreshToken, hashToken }

redis.js

// redis.js
const Redis = require('ioredis')
const redis = new Redis(process.env.REDIS_URL)
module.exports = redis

server.js — main app

// server.js
require('dotenv').config()
const express = require('express')
const cookieParser = require('cookie-parser')
const bcrypt = require('bcryptjs')
const { v4: uuidv4 } = require('uuid')
const speakeasy = require('speakeasy')
const qrcode = require('qrcode')
const {
  signAccessToken,
  signRefreshToken,
  verifyRefreshToken,
  hashToken
} = require('./tokens')
const redis = require('./redis')
const app = express()
app.use(express.json())
app.use(cookieParser())
/**
 * Demo users store (replace with real DB)
 * - passwordHash: bcrypt hash
 * - secret: base32 2FA secret (if enabled)
 * - is2FAEnabled: boolean
 */
const users = [
  { id: 'user-1', username: 'alice', passwordHash: bcrypt.hashSync('password123', 8), secret: null, is2FAEnabled: false, roles: ['user'] }
]
// Utility: set refresh token cookie (HttpOnly)
function setRefreshCookie(res, token) {
  const isProd = process.env.NODE_ENV === 'production'
  res.cookie('refreshToken', token, {
    httpOnly: true,
    secure: isProd,
    sameSite: 'lax',
    path: '/auth/refresh',
    maxAge: 14 * 24 * 60 * 60 * 1000
  })
}
// ----------------- LOGIN (step 1) -----------------
// Submit username/password. If user has 2FA -> respond requires2FA + temp id.
// Else issue tokens directly.
app.post('/auth/login', async (req, res) => {
  const { username, password } = req.body
  const user = users.find(u => u.username === username)
  if (!user) return res.status(401).json({ error: 'Invalid credentials' })
  const ok = await bcrypt.compare(password, user.passwordHash)
  if (!ok) return res.status(401).json({ error: 'Invalid credentials' })
  if (user.is2FAEnabled) {
    // For 2FA flow we return a temporary auth id (nonce) to tie the second step.
    const t = uuidv4()
    // store temporary mapping in redis with short TTL (5 minutes)
    await redis.setex(`2fa:${t}`, 300, JSON.stringify({ uid: user.id }))
    return res.json({ requires2FA: true, tempId: t })
  }
  // no 2FA: issue tokens
  const sessionId = uuidv4()
  const refreshToken = signRefreshToken({ sid: sessionId, uid: user.id })
  const refreshHash = hashToken(refreshToken)
  await redis.hmset(`refresh:${sessionId}`, { uid: user.id, hash: refreshHash, revoked: 0 })
  await redis.expire(`refresh:${sessionId}`, 14 * 24 * 60 * 60)
  setRefreshCookie(res, refreshToken)
  const accessToken = signAccessToken({ uid: user.id, roles: user.roles })
  res.json({ accessToken, user: { id: user.id, username: user.username, roles: user.roles } })
})
// ----------------- 2FA SETUP (generate QR) -----------------
app.post('/2fa/setup', async (req, res) => {
  const { userId } = req.body
  const user = users.find(u => u.id === userId)
  if (!user) return res.status(404).json({ error: 'User not found' })
  const secret = speakeasy.generateSecret({ length: 20 })
  user.secret = secret.base32
  user.is2FAEnabled = false // not enabled until verify step
  const qr = await qrcode.toDataURL(secret.otpauth_url)
  res.json({ secret: user.secret, qr })
})
// ----------------- 2FA VERIFY (during setup or login) -----------------
app.post('/2fa/verify', async (req, res) => {
  const { userId, token, tempId } = req.body
  let user = null
  if (tempId) {
    const data = await redis.get(`2fa:${tempId}`)
    if (!data) return res.status(400).json({ error: 'Invalid or expired 2FA flow' })
    const parsed = JSON.parse(data)
    user = users.find(u => u.id === parsed.uid)
    if (!user) return res.status(404).json({ error: 'User not found' })
  } else {
    user = users.find(u => u.id === userId)
    if (!user) return res.status(404).json({ error: 'User not found' })
  }
  if (!user.secret) return res.status(400).json({ error: '2FA not configured' })
  const verified = speakeasy.totp.verify({
    secret: user.secret,
    encoding: 'base32',
    token,
    window: 1 // allow 1 step window (optional)
  })
  if (!verified) return res.json({ verified: false })
  // If verification is for setup, enable 2FA and finish
  if (!tempId && user && !user.is2FAEnabled) {
    user.is2FAEnabled = true
    return res.json({ verified: true })
  }
  // If verification is for login flow (tempId present), issue tokens
  if (tempId) {
    // cleanup temp key
    await redis.del(`2fa:${tempId}`)
    // issue rotated refresh token/session like normal login
    const sessionId = uuidv4()
    const refreshToken = signRefreshToken({ sid: sessionId, uid: user.id })
    const refreshHash = hashToken(refreshToken)
    await redis.hmset(`refresh:${sessionId}`, { uid: user.id, hash: refreshHash, revoked: 0 })
    await redis.expire(`refresh:${sessionId}`, 14 * 24 * 60 * 60)
    setRefreshCookie(res, refreshToken)
    const accessToken = signAccessToken({ uid: user.id, roles: user.roles })
    return res.json({ verified: true, accessToken, user: { id: user.id, username: user.username, roles: user.roles } })
  }
  res.json({ verified: true })
})
// ----------------- REFRESH (rotation + reuse detection) -----------------
app.post('/auth/refresh', async (req, res) => {
  const token = req.cookies && req.cookies.refreshToken
  if (!token) return res.status(401).json({ error: 'No refresh token' })
  let payload
  try {
    payload = verifyRefreshToken(token)
  } catch (err) {
    return res.status(401).json({ error: 'Invalid refresh token' })
  }
  const sid = payload.sid
  const key = `refresh:${sid}`
  const record = await redis.hgetall(key)
  // If no record - possible reuse or already rotated/deleted
  if (!record || !record.hash) {
    // Reuse detection: revoke all sessions for user
    await revokeAllUserSessions(payload.uid)
    return res.status(401).json({ error: 'Refresh token reuse detected' })
  }
  const providedHash = hashToken(token)
  if (providedHash !== record.hash) {
    // reuse detected
    await revokeAllUserSessions(payload.uid)
    return res.status(401).json({ error: 'Refresh token reuse detected (hash mismatch)' })
  }
  if (record.revoked === '1') {
    await revokeAllUserSessions(payload.uid)
    return res.status(401).json({ error: 'Refresh token revoked' })
  }
  // Rotate: issue new refresh token with new session id
  const newSid = uuidv4()
  const newRefresh = signRefreshToken({ sid: newSid, uid: payload.uid })
  const newHash = hashToken(newRefresh)
  const newKey = `refresh:${newSid}`
  // Persist new token record
  await redis.hmset(newKey, { uid: payload.uid, hash: newHash, revoked: 0, rotatedFrom: sid })
  await redis.expire(newKey, 14 * 24 * 60 * 60)
  // Clear old hash (prevent reuse) but keep record for auditing. Mark rotated.
  await redis.hmset(key, { rotated: 1, rotatedTo: newSid })
  await redis.hset(key, 'hash', '')
  setRefreshCookie(res, newRefresh)
  // Issue new access token
  const user = users.find(u => u.id === payload.uid)
  const accessToken = signAccessToken({ uid: user.id, roles: user.roles })
  return res.json({ accessToken, user: { id: user.id, username: user.username, roles: user.roles } })
})
// ----------------- LOGOUT (revoke current session) -----------------
app.post('/auth/logout', async (req, res) => {
  const token = req.cookies && req.cookies.refreshToken
  if (token) {
    try {
      const p = verifyRefreshToken(token)
      const key = `refresh:${p.sid}`
      await redis.hset(key, 'revoked', 1)
      await redis.hset(key, 'revokedAt', new Date().toISOString())
    } catch (e) { /* ignore */ }
  }
  res.clearCookie('refreshToken', { path: '/auth/refresh' })
  res.json({ ok: true })
})
// ----------------- revokeAllUserSessions helper -----------------
async function revokeAllUserSessions(uid) {
  // For demo: naive scan. In production maintain a per-user index or DB records.
  const keys = await redis.keys('refresh:*')
  for (const k of keys) {
    const rec = await redis.hgetall(k)
    if (rec.uid === uid) {
      await redis.hset(k, 'revoked', 1)
      await redis.hset(k, 'revokedAt', new Date().toISOString())
    }
  }
}
app.listen(process.env.PORT || 4000, () => console.log('Auth server listening on 4000'))

Notes & production considerations:

  • Replace the in-memory users array with a proper database. Store 2FA secrets securely (encrypt at rest).
  • Instead of scanning Redis keys to revoke user sessions, maintain a user → sessions index (e.g., Redis set userSessions:{uid} storing session keys) to revoke quickly at scale.
  • Hash refresh tokens before storing; do not store plaintext refresh tokens (we used a salted HMAC with a pepper here). For more protection, use bcrypt with appropriate cost if you can afford CPU cost.
  • Log events: rotations, reuse detections, revoked sessions, and failed verification attempts.

Vue client: secure usage patterns and axios interceptor

Client responsibilities:

  • Keep access token in memory (Pinia or local state). Do not store refresh token in JS — server sets HttpOnly cookie.
  • Call /auth/refresh when access token expires. The cookie will be sent automatically to that path.
  • On app start, attempt a silent refresh to restore session if cookie exists.

axios-client.js (example)

// axios-client.js (client-side)
import axios from 'axios'
import { useAuthStore } from '@/stores/auth' // Pinia store that keeps accessToken
const api = axios.create({
  baseURL: 'http://localhost:4000',
  withCredentials: true // important so refresh cookie is sent
})
let isRefreshing = false
let subscribers = []
function subscribeTokenRefresh(cb) {
  subscribers.push(cb)
}
function onRefreshed(token) {
  subscribers.forEach(cb => cb(token))
  subscribers = []
}
api.interceptors.request.use(config => {
  const auth = useAuthStore()
  if (auth.accessToken) {
    config.headers.Authorization = `Bearer ${auth.accessToken}`
  }
  return config
}, error => Promise.reject(error))
api.interceptors.response.use(resp => resp, err => {
  const originalRequest = err.config
  if (err.response && err.response.status === 401 && !originalRequest._retry) {
    originalRequest._retry = true
    const auth = useAuthStore()
    if (isRefreshing) {
      // queue the request until refresh completes
      return new Promise((resolve, reject) => {
        subscribeTokenRefresh((token) => {
          originalRequest.headers.Authorization = 'Bearer ' + token
          resolve(api(originalRequest))
        })
      })
    }
    isRefreshing = true
    return new Promise((resolve, reject) => {
      axios.post('http://localhost:4000/auth/refresh', {}, { withCredentials: true })
        .then(({ data }) => {
          auth.setAccessToken(data.accessToken)
          onRefreshed(data.accessToken)
          originalRequest.headers.Authorization = 'Bearer ' + data.accessToken
          resolve(api(originalRequest))
        })
        .catch((err) => {
          // refresh failed (possibly reuse detection) -> logout
          auth.clearAuth()
          reject(err)
        })
        .finally(() => { isRefreshing = false })
    })
  }
  return Promise.reject(err)
})
export default api

Pinia auth store (simplified)

// stores/auth.ts (Pinia)
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAuthStore = defineStore('auth', () => {
  const accessToken = ref(null)
  const user = ref(null)
  function setAccessToken(token) { accessToken.value = token }
  function setUser(u) { user.value = u }
  function clearAuth() { accessToken.value = null; user.value = null }
  return { accessToken, user, setAccessToken, setUser, clearAuth }
})

App init: try silent refresh

// main.js or App.vue mounted
import api from './axios-client'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
async function initAuth() {
  try {
    const { data } = await api.post('/auth/refresh', {}, { withCredentials: true })
    auth.setAccessToken(data.accessToken)
    auth.setUser(data.user)
  } catch (e) {
    // no valid session
    auth.clearAuth()
  }
}
initAuth()

Login flow with 2FA:

  1. Client POST /auth/login with username/password.
  2. If response says requires2FA, client shows OTP input and sends OTP + tempId to /2fa/verify.
  3. On success, backend sets refresh cookie and returns access token; client stores the access token in memory.

Testing & verification

Test scenarios to validate rotating refresh token behavior:

  1. Normal login -> access token issued -> refresh used -> new refresh cookie set -> old token invalidated (hash cleared) -> subsequent use of old refresh token should trigger reuse detection.
  2. Simulate stolen refresh token: save an earlier cookie value and use it after rotation — server should detect reuse and revoke all sessions.
  3. Try logout: refresh record marked revoked and cookie cleared.
  4. Test 2FA flow: login with credentials for a 2FA-enabled user; ensure tokens only issued after OTP verification.

Operational & production tips

  • Index sessions: maintain userSessions:{uid} in Redis to list/revoke sessions efficiently.
  • Audit logs: record rotation events, reuse detections, and revocations for incident response.
  • Rate-limit: protect /auth/refresh and /auth/login from abuse.
  • Secure secrets: rotate JWT secrets periodically and support key versions to gracefully rotate existing sessions.
  • Monitoring: alert on spikes of reuse detection events — could be a breach attempt.

Summary checklist

  1. Only issue access tokens after successful 2FA verification for 2FA-enabled users.
  2. Store refresh token state server-side (Redis/DB) and hash tokens before storage.
  3. Rotate refresh tokens on each refresh and blank old hash to detect reuse.
  4. On reuse detection, revoke sessions, clear cookies, and require re-authentication.
  5. Keep access tokens short-lived and store them only in memory client-side.
This website uses cookies to enhance your browsing experience. By continuing to use this site, you consent to the use of cookies. Please review our Privacy Policy for more information on how we handle your data. Cookie Policy