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.
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:
- Client POST /auth/login with username/password.
- If response says
requires2FA, client shows OTP input and sends OTP + tempId to /2fa/verify. - On success, backend sets refresh cookie and returns access token; client stores the access token in memory.