Revoking tokens across distributed systems: reference vs self-contained

Token revocation is one of the fundamental tensions in auth system design. You want fast, stateless verification (which JWTs provide) but you also want immediate revocation when a user logs out, a session is compromised, or an account is suspended. These two requirements pull in opposite directions. Understanding the trade-offs clearly — rather than picking one approach and pretending the problem is solved — is the foundation of a revocation strategy that actually works.

Reference tokens: always fresh, always a round trip

A reference token is an opaque random string that acts as a pointer to token data stored server-side. Each API request that presents a reference token requires a lookup in your token store (database or Redis) to retrieve the associated claims and verify validity. Revocation is instant: delete the entry from the store and the token is immediately invalid on the next request.

// Reference token implementation
import crypto from 'crypto';

// Issue reference token
async function issueReferenceToken(userId, orgId, scopes) {
  const token = crypto.randomBytes(32).toString('base64url');
  const tokenId = `rt_${token}`;

  await redis.setex(`token:${tokenId}`, 3600, JSON.stringify({
    userId,
    orgId,
    scopes,
    issuedAt: Date.now(),
  }));

  return tokenId;
}

// Verify reference token (called on every API request)
async function verifyReferenceToken(tokenId) {
  const data = await redis.get(`token:${tokenId}`);
  if (!data) return null;  // not found = expired or revoked

  // Optionally reset TTL on use (sliding expiry)
  await redis.expire(`token:${tokenId}`, 3600);

  return JSON.parse(data);
}

// Revoke reference token (instant)
async function revokeReferenceToken(tokenId) {
  await redis.del(`token:${tokenId}`);
  // The token is now invalid — no waiting for expiry
}

The cost is latency: every API request makes at least one Redis call (typically 0.5–2ms within a data center). At high request rates this adds up, and Redis becomes a critical dependency — if your token store is unavailable, no API requests can be authorized.

Self-contained JWTs: fast, stale until expiry

A JWT contains all necessary claims and is cryptographically self-verifying. No storage lookup required. Verification takes 0.1–0.3ms using cached JWKS. The fundamental limitation: once issued, a JWT is valid until its exp claim. You cannot revoke it directly — the token is a signed fact, and changing the signature would invalidate it.

// JWT has no revocation mechanism built in
// The best you can do is wait for expiry or maintain a blocklist

// On user logout: you can invalidate the refresh token
// but the access JWT remains valid for up to its lifetime
async function logout(userId, sessionId, accessTokenJti) {
  // Invalidate refresh token (user cannot get new access tokens)
  await db.query(
    'UPDATE sessions SET revoked_at = NOW() WHERE id = $1',
    [sessionId]
  );

  // The access JWT is still valid for up to 15 more minutes
  // Option 1: accept this (the trade-off for stateless JWT)
  // Option 2: add jti to a blocklist
  await redis.zadd(
    'jwt_blocklist',
    Date.now() + 900000,  // score = expiry timestamp (for cleanup)
    accessTokenJti
  );
}

Hybrid approach: short-lived JWTs + refresh token revocation

The production standard: issue JWTs with short lifetimes (5–15 minutes), verified locally with no round trip. Maintain revocability through the refresh token layer — revoking the refresh token means the user cannot get new access tokens after the current one expires.

// Token issuance: short-lived JWT + long-lived refresh token
async function issueTokenPair(userId, orgId, sessionId) {
  // Access token: short-lived JWT, verified locally
  const accessToken = await signJWT({
    sub: userId,
    org: orgId,
    sid: sessionId,
    jti: crypto.randomUUID(),
  }, { expiresIn: '15m' });

  // Refresh token: opaque, stored in DB for revocability
  const refreshToken = crypto.randomBytes(32).toString('base64url');
  const refreshHash = createHash('sha256').update(refreshToken).digest('hex');

  await db.query(`
    INSERT INTO refresh_tokens (session_id, token_hash, user_id, org_id, expires_at)
    VALUES ($1, $2, $3, $4, NOW() + INTERVAL '30 days')
  `, [sessionId, refreshHash, userId, orgId]);

  return { accessToken, refreshToken };
}

// Revoke entire session (e.g., admin suspends account)
async function revokeSession(sessionId) {
  await db.query(
    'UPDATE sessions SET revoked_at = NOW() WHERE id = $1',
    [sessionId]
  );
  await db.query(
    'UPDATE refresh_tokens SET revoked_at = NOW() WHERE session_id = $1',
    [sessionId]
  );
  // Access JWT for this session is still valid up to 15 minutes
  // Acceptable for most use cases; use jti blocklist for immediate effect
}

// Refresh flow: checks session validity before issuing new access token
async function refreshAccessToken(rawRefreshToken) {
  const hash = createHash('sha256').update(rawRefreshToken).digest('hex');

  const rt = await db.query(`
    SELECT rt.*, s.revoked_at as session_revoked_at
    FROM refresh_tokens rt
    JOIN sessions s ON s.id = rt.session_id
    WHERE rt.token_hash = $1
      AND rt.revoked_at IS NULL
      AND rt.expires_at > NOW()
      AND s.revoked_at IS NULL  -- session-level revocation check
  `, [hash]);

  if (!rt.rows[0]) throw new Error('Invalid or revoked refresh token');

  return issueTokenPair(rt.rows[0].user_id, rt.rows[0].org_id, rt.rows[0].session_id);
}

JTI blocklist for immediate revocation

When you need truly immediate revocation — account compromise, fraud detection, legal hold — a JTI blocklist combined with short-lived JWTs provides the best balance. The blocklist is a small set (only explicitly revoked tokens, not all issued tokens) that fits in Redis memory and can be checked in a single O(1) ZSCORE operation.

// JTI blocklist: only add when immediate revocation is needed
// For normal logout, rely on refresh token revocation

async function revokeTokenImmediately(jti, expiresAt) {
  // Score = expiry Unix timestamp (for automatic cleanup)
  await redis.zadd('jwt_blocklist', expiresAt, jti);
}

// In JWT verification middleware
async function verifyToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://auth.yourapp.com',
    algorithms: ['ES256'],
  });

  // Only check blocklist if the token has a jti
  if (payload.jti) {
    const score = await redis.zscore('jwt_blocklist', payload.jti);
    if (score !== null) throw new Error('Token has been revoked');
  }

  return payload;
}

// Periodic cleanup: remove expired entries from blocklist
// Run daily via cron — keeps the set from growing unbounded
async function pruneBlocklist() {
  const now = Math.floor(Date.now() / 1000);
  const removed = await redis.zremrangebyscore('jwt_blocklist', '-inf', now);
  console.log(`Pruned ${removed} expired entries from JWT blocklist`);
}
The blocklist is safe as long as your JWT lifetime is short. If a JWT is valid for 15 minutes, the blocklist only needs to track tokens revoked in the last 15 minutes. With a million logins per day and an average of 0.01% requiring immediate revocation, the blocklist holds ~100 entries at any time — trivially small for Redis. The math changes drastically if your JWT lifetime is 24 hours.

Choose your approach based on your security requirements: pure reference tokens for regulated industries requiring instant revocation, hybrid JWT + refresh revocation for most SaaS applications, and add a JTI blocklist selectively for high-value revocation events. There is no single right answer — only trade-offs that must match your threat model.

← Back to blog Try Bastionary free →