• Fri, Mar 2026

Implement Token Rotation & Refresh Token Revocation in Vue.js Backends

Implement Token Rotation & Refresh Token Revocation in Vue.js Backends

In modern single-page apps (Vue, React, etc.) authentication commonly uses short-lived access tokens (JWT) and longer-lived refresh tokens. To reduce risk from stolen refresh tokens we must implement refresh token rotation and revocation. This tutorial explains and provides full real-world example code (Node/Express backend + small client snippets using axios).

Why refresh token rotation & revocation matter

Short-lived access tokens limit risk when an access token leaks. But refresh tokens — if stolen — allow attackers to mint new access tokens. Simple refresh token use (one static token per session) leaves you open to long-lived compromise. Rotation + revocation addresses this:

  • Rotation: Every time the client exchanges a refresh token for a new access token, the backend issues a new refresh token and invalidates the previous one. That limits the window an attacker can reuse a stolen refresh token.
  • Revocation / Reuse detection: If you detect the same refresh token being used twice (replay), treat this as token theft — revoke the session and all related tokens, force re-login, and optionally alert the user.

Rotation + reuse detection prevents “one-stolen-token = forever access.” It also gives you a chance to detect breaches early and act (invalidate session, block account, notify user).

High-level flow

  1. User logs in -> server authenticates and returns accessToken and a refreshToken (refresh token stored server-side with metadata).
  2. Client stores access token in memory, refresh token in an HttpOnly, Secure cookie (or other secure mechanism).
  3. When the access token expires, client calls /auth/refresh and sends the refresh token (cookie automatically sent).
  4. Server verifies the refresh token, checks DB/Redis, detects if token is valid and not rotated/reused.
  5. If valid: server issues a new access token and a new refresh token, stores the new refresh token and marks the previous one as rotated (or deletes it).
  6. If a rotated/used refresh token is presented (reuse): treat as theft -> invalidate session, revoke all tokens for that session, require re-authentication, optionally log and notify.

Architecture & storage choices

Where to store refresh tokens server-side? Two common approaches:

1. Database (SQL / NoSQL)

Store refresh tokens in a DB table/collection with fields: token id/hash, user id, expiry, createdAt, rotatedFrom (optional), revoked boolean, meta (ip, user-agent). Pros: durable, audits, easy to list/revoke. Cons: additional DB calls (but normal in auth flows).

2. Redis (fast key-value)

Use Redis to store refresh token state keyed by token id. Pros: very fast, TTL built-in. Cons: if Redis is not persisted and you crash, sessions may be lost unless you persist or accept that.

For production, a combination often works: store canonical session and token metadata in DB, use Redis as cache for quick lookups and TTLs. This tutorial will show examples using Redis for token state and an SQL schema example for durability.

Security considerations

  • Use HttpOnly, Secure, SameSite cookies for refresh tokens to reduce XSS risk.
  • Keep access tokens short (minutes). Keep refresh tokens longer but rotate them frequently.
  • Rotate refresh tokens on every refresh response.
  • Store only token hashes (not raw tokens) server-side if possible — compare hashes to prevent token DB leaks revealing raw tokens.
  • Add metadata (IP, user-agent) to detect anomalies.

Database schema (example)

SQL table example for refresh tokens / sessions:

-- SQL (Postgres) example
CREATE TABLE user_sessions (
  id BIGSERIAL PRIMARY KEY,
  user_id UUID NOT NULL,
  refresh_token_hash TEXT NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
  last_used_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
  expires_at TIMESTAMP WITH TIME ZONE,
  rotated_from TEXT, -- previous token hash/id
  revoked BOOLEAN DEFAULT FALSE,
  revoked_at TIMESTAMP WITH TIME ZONE,
  ip TEXT,
  user_agent TEXT
);

CREATE INDEX idx_user_sessions_user ON user_sessions(user_id);

Note: store token hashes, not plaintext tokens. Use bcrypt or SHA-256 with a server-side pepper for hashing (bcrypt for comparisons vs raw token is better but slower; SHA-256 with server pepper and timing-safe compare is an option).

Step-by-step backend example (Node.js + Express + Redis)

We’ll implement a minimal but complete backend showing:

  • /auth/login
  • /auth/refresh
  • /auth/logout
  • reuse-detection and revocation

Dependencies

npm install express jsonwebtoken bcryptjs ioredis uuid cookie-parser dotenv

Env variables (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

Helper: token utils (tokens.js)

// tokens.js
const jwt = require('jsonwebtoken')
const crypto = require('crypto')

function signAccessToken(payload) {
  return jwt.sign(payload, process.env.JWT_ACCESS_SECRET, { expiresIn: process.env.ACCESS_TOKEN_EXP || '15m' })
}

function signRefreshToken(payload) {
  // Keep refresh token payload small (id and session)
  return jwt.sign(payload, process.env.JWT_REFRESH_SECRET, { expiresIn: process.env.REFRESH_TOKEN_EXP || '14d' })
}

function verifyRefreshToken(token) {
  return jwt.verify(token, process.env.JWT_REFRESH_SECRET)
}

function hashToken(token) {
  // Use HMAC-SHA256 with a pepper to hash tokens before storing
  const h = crypto.createHmac('sha256', process.env.TOKEN_PEPPER || 'pepper')
  h.update(token)
  return h.digest('hex')
}

module.exports = { signAccessToken, signRefreshToken, verifyRefreshToken, hashToken }

Redis client (redis.js)

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

Main server (server.js) — simplified but clear

// server.js (Node/Express)
require('dotenv').config()
const express = require('express')
const cookieParser = require('cookie-parser')
const { v4: uuidv4 } = require('uuid')
const bcrypt = require('bcryptjs') // for demo user password
const redis = require('./redis')
const { signAccessToken, signRefreshToken, verifyRefreshToken, hashToken } = require('./tokens')

const app = express()
app.use(express.json())
app.use(cookieParser())

// demo "users" store (in production use DB)
const users = [
  { id: 'user-1', email: 'alice@example.com', passwordHash: bcrypt.hashSync('password123', 8), roles: ['user'] },
  { id: 'admin-1', email: 'admin@example.com', passwordHash: bcrypt.hashSync('adminpass', 8), roles: ['admin'] }
]

// helper set refresh token in HttpOnly cookie
function setRefreshTokenCookie(res, token) {
  res.cookie('refreshToken', token, {
    httpOnly: true,
    secure: true, // set to true on production (HTTPS)
    sameSite: 'lax',
    path: '/auth/refresh', // limit cookie path to refresh endpoint for extra safety
    maxAge: 14 * 24 * 60 * 60 * 1000 // match refresh exp
  })
}

// LOGIN
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body
  const user = users.find(u => u.email === email)
  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' })

  // Create session id and refresh token
  const sessionId = uuidv4()
  const refreshToken = signRefreshToken({ sid: sessionId, uid: user.id })
  const refreshHash = hashToken(refreshToken)

  // Save refresh token state in Redis (or DB)
  const key = `refresh:${sessionId}`
  await redis.hmset(key, {
    uid: user.id,
    hash: refreshHash,
    revoked: 0,
    rotatedFrom: '',
  })
  // set TTL to match refresh token expiration (14 days)
  await redis.expire(key, 14 * 24 * 60 * 60)

  // set httpOnly cookie
  setRefreshTokenCookie(res, refreshToken)

  // issue access token (short-lived)
  const accessToken = signAccessToken({ uid: user.id, roles: user.roles })
  res.json({ accessToken, user: { id: user.id, roles: user.roles } })
})

// REFRESH
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) // contains sid and uid
  } catch (err) {
    return res.status(401).json({ error: 'Invalid refresh token' })
  }

  const sessionId = payload.sid
  const key = `refresh:${sessionId}`
  const record = await redis.hgetall(key)

  if (!record || !record.hash) {
    // No record => possible reuse or already revoked
    // treat as theft, revoke sibling sessions for user
    await revokeAllUserSessions(payload.uid)
    return res.status(401).json({ error: 'Refresh token reuse detected (no record)' })
  }

  const providedHash = hashToken(token)
  if (providedHash !== record.hash) {
    // Token hash mismatch — reuse or tampering
    await revokeAllUserSessions(payload.uid)
    return res.status(401).json({ error: 'Refresh token reuse detected (hash mismatch)' })
  }

  // If record.revoked === '1' treat as revoked
  if (record.revoked === '1') {
    await revokeAllUserSessions(payload.uid)
    return res.status(401).json({ error: 'Refresh token revoked' })
  }

  // Rotation: create new session id (new refresh token) and persist
  const newSessionId = uuidv4()
  const newRefreshToken = signRefreshToken({ sid: newSessionId, uid: payload.uid })
  const newHash = hashToken(newRefreshToken)
  const newKey = `refresh:${newSessionId}`

  // Store new token record and mark old record as rotated (store rotatedFrom)
  await redis.hmset(newKey, {
    uid: payload.uid,
    hash: newHash,
    revoked: 0,
    rotatedFrom: sessionId,
  })
  await redis.expire(newKey, 14 * 24 * 60 * 60)

  // Optionally mark old token as rotated and delete its hash (or keep for audit)
  await redis.hmset(key, { rotated: 1, rotatedTo: newSessionId })
  await redis.hset(key, 'hash', '') // blanking out old hash prevents reuse
  // no immediate deletion to allow detection of reuse if stolen token used later

  // set new cookie
  setRefreshTokenCookie(res, newRefreshToken)

  // create and return new access token
  const user = users.find(u => u.id === payload.uid)
  const accessToken = signAccessToken({ uid: user.id, roles: user.roles })
  res.json({ accessToken, user: { id: user.id, roles: user.roles } })
})

// LOGOUT (invalidate a specific 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, 'revoked_at', new Date().toISOString())
    } catch (e) { /* ignore */ }
  }
  // clear cookie on client
  res.clearCookie('refreshToken', { path: '/auth/refresh' })
  res.json({ ok: true })
})

// revoke helper: revoke all sessions for a user (used on reuse detection)
async function revokeAllUserSessions(uid) {
  // For demo: we scan keys (not ideal for large scale); in production maintain index or DB table per user
  const keys = await redis.keys('refresh:*')
  for (const key of keys) {
    const rec = await redis.hgetall(key)
    if (rec.uid === uid) {
      await redis.hset(key, 'revoked', 1)
      await redis.hset(key, 'revoked_at', new Date().toISOString())
    }
  }
}

app.listen(process.env.PORT || 4000, () => {
  console.log('Auth server listening...')
})

Notes about the server implementation:

  • We sign refresh tokens with a separate secret (JWT_REFRESH_SECRET) and include a session id sid and user id in the token payload.
  • We store a server-side hash of the refresh token and blank it out on rotation to detect reuse of an old token.
  • On refresh we create a new session id + refresh token and rotate the old one; we do not immediately delete the old record — keeping it allows detection of reuse if stolen token is presented later.
  • On detected reuse (hash mismatch or missing record) we revoke all user sessions to contain the breach.
  • Cookie properties: HttpOnly, Secure, SameSite. Limit cookie path to the refresh endpoint for added security.

Client-side (Vue) usage notes & axios snippet

On the client, store access token in memory (Vuex/Pinia or local state). Store refresh token only in HttpOnly cookie; do not read it in JS. When access token expires, call /auth/refresh (cookie will be sent automatically). Example axios client with refresh handling:

// axios-client.js (client-side)
import axios from 'axios'
import store from './store' // your Vue store

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

let isRefreshing = false
let failedQueue = []

const processQueue = (error, token = null) => {
  failedQueue.forEach(prom => {
    if (error) prom.reject(error)
    else prom.resolve(token)
  })
  failedQueue = []
}

api.interceptors.request.use(config => {
  const token = store.state.auth.accessToken
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

api.interceptors.response.use(response => response, err => {
  const originalRequest = err.config
  if (err.response && err.response.status === 401 && !originalRequest._retry) {
    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        failedQueue.push({ resolve, reject })
      }).then(token => {
        originalRequest.headers.Authorization = 'Bearer ' + token
        return axios(originalRequest)
      })
    }

    originalRequest._retry = true
    isRefreshing = true

    return new Promise((resolve, reject) => {
      axios.post('/api/auth/refresh', {}, { withCredentials: true })
        .then(({ data }) => {
          store.commit('auth/setAccessToken', data.accessToken)
          api.defaults.headers.common['Authorization'] = 'Bearer ' + data.accessToken
          processQueue(null, data.accessToken)
          resolve(api(originalRequest))
        })
        .catch((err) => {
          processQueue(err, null)
          store.dispatch('auth/logout') // force logout on client
          reject(err)
        })
        .finally(() => { isRefreshing = false })
    })
  }
  return Promise.reject(err)
})

export default api

Client best practices: Keep the access token in memory only; when the page reloads, call /auth/refresh once during app init to restore a new access token if refresh cookie exists. This avoids storing refresh tokens in localStorage and reduces XSS risk.

Token reuse detection: real scenarios & response

When should you treat reuse as theft?

  • Token presented but no server record exists (missing key).
  • Token hash mismatch (someone used a prior token that was rotated).
  • Token used after it was revoked.

Actions to take when reuse is detected:

  1. Revoke all sessions for that user (invalidate refresh tokens server-side).
  2. Invalidate access tokens if you manage a revocation list (or rely on short expiry for access tokens).
  3. Force re-authentication (require login).
  4. Notify the user by email or in-app message (optional but recommended).
  5. Log IP/user-agent and escalate if thresholds exceeded.

Operational considerations

Scaling

On a cluster, store refresh token state in a shared datastore (Redis/DB). Avoid in-memory storage on a single server.

Auditing

Keep audit trails for session creation, rotations, and revocations. This helps with incident response.

Cleaning stale sessions

Implement cron jobs or TTL-based expiry to purge old sessions and free storage.

Testing your implementation

Manual tests:

  1. Login and verify access token works and HTTP-only cookie was set.
  2. Use the app until access token expires, then verify refresh flow obtains new tokens.
  3. Simulate reuse: copy an old refresh token (saved earlier) and attempt to refresh — the server should detect reuse and revoke sessions.
  4. Test logout: ensure cookie is cleared and server marks the session revoked.

Automated tests: write unit/integration tests that mock JWTs, Redis entries and assert rotation behavior, reuse detection and revocation.

Summary & Checklist

Key steps to implement secure refresh token rotation and revocation:

  • Issue short-lived access tokens and longer-lived refresh tokens.
  • Store refresh tokens server-side as hashes with session metadata.
  • Rotate refresh tokens on every refresh request (issue new refresh token & server record).
  • Blank or mark previous token record so reuse is detectable.
  • On reuse detection, revoke all related sessions and require re-authentication.
  • Use HttpOnly, Secure cookies for refresh tokens and keep access tokens in memory only.
  • Log and notify on suspicious activity; maintain audit trail.
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