How to Invalidate JWTs on Logout Without a Database

The first time I tried to "log out" a user with JWT, I stared at my screen for fifteen minutes wondering why this was so hard.

I had just migrated from session-based auth — where logout was as simple as DELETE FROM sessions WHERE session_id = ? — to stateless JWT auth. The access token was signed, self-contained, and verified without touching the database. Beautiful for performance. Terrible for revocation.

The user clicked "Log Out." The frontend deleted the token from localStorage. The server had no way to know. The old token was still valid. It would remain valid until it expired — 15 minutes later in our case, but I've seen systems with 24-hour access token lifetimes.

This is the fundamental tension of stateless JWTs: you cannot unsend an email. Once a token is issued, anyone who holds it can use it until it expires.

But you do not need a full database lookup on every request to solve this. Here are five strategies that work in production, ordered from simplest to most robust.


Strategy 1: Short Expiration Times (The Baseline)

The simplest approach is to make access tokens expire quickly. If your token lasts 5 minutes, logout means "the token is unusable within 5 minutes." For many applications, this is sufficient.

const jwt = require('jsonwebtoken');

// Access token: 5 minute lifetime
const accessToken = jwt.sign(
  { userId: user.id, role: user.role },
  ACCESS_TOKEN_SECRET,
  { expiresIn: '5m' }
);

// Verify — automatically checks exp
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.sendStatus(401);

  try {
    req.user = jwt.verify(token, ACCESS_TOKEN_SECRET);
    next();
  } catch {
    res.sendStatus(401);
  }
}

This is not a full solution. A stolen token is still usable for 5 minutes. But it limits the damage window to minutes instead of hours or days. Combined with refresh token rotation — as covered in JWT Refresh Token Implementation — it is a solid baseline.

The tradeoff: shorter lifetimes mean more frequent refresh token calls. Every 5 minutes the client must hit your auth endpoint. This is fine for most SPAs but matters for mobile apps on unreliable networks.


Strategy 2: Token Version Claim (User-Level Invalidation)

Store a version counter on the user record and include it in the JWT at issuance time. On logout, increment the counter. On verification, check the counter matches.

This requires no per-token database state. It only needs the user record — which you almost certainly already have cached.

Implementation

// Login — issue token with current version
async function issueAccessToken(user) {
  const token = jwt.sign(
    {
      sub: user.id,
      ver: user.token_version, // integer, incremented on logout
    },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  );
  return token;
}

// Logout — increment version, invalidating all existing tokens
async function logoutAllSessions(userId) {
  await db.query(
    'UPDATE users SET token_version = token_version + 1 WHERE id = ?',
    [userId]
  );
}

// Verify — check version matches
function verifyAccessToken(token) {
  try {
    const payload = jwt.verify(token, ACCESS_SECRET);

    // Fetch user version (cache with Redis, 60s TTL)
    const currentVersion = await getCachedUserVersion(payload.sub);

    if (payload.ver !== currentVersion) {
      throw new Error('Token version mismatch — user logged out');
    }

    return payload;
  } catch (err) {
    throw err;
  }
}

// Redis cache helper
const tokenVersionCache = new Map();

async function getCachedUserVersion(userId) {
  const cacheKey = `token_ver:${userId}`;

  if (tokenVersionCache.has(cacheKey)) {
    return tokenVersionCache.get(cacheKey);
  }

  const row = await db.query(
    'SELECT token_version FROM users WHERE id = ?',
    [userId]
  );

  const version = row[0].token_version;
  tokenVersionCache.set(cacheKey, version);
  setTimeout(() => tokenVersionCache.delete(cacheKey), 60000); // 60s cache

  return version;
}

Pros and Cons

  • Pro: Logs out all sessions at once. Perfect for password changes, account suspension, or "log out everywhere."
  • Pro: No per-token database writes. Single user record update.
  • Con: Logs out ALL sessions. You cannot selectively invalidate one device.
  • Con: Requires a cache hit on every request. If the cache misses, you hit the database.
  • Con: The version check adds latency. In our benchmarks, Redis adds ~1ms per request. Acceptable for most APIs.

Strategy 3: Deny List (Token Blacklist with TTL)

Maintain a short-lived deny list of recently invalidated token IDs (the jti claim). The list is not a full database lookup — it is an in-memory cache (Redis) with automatic TTL.

How It Works

  1. Every issued JWT includes a unique jti (JWT ID) claim.
  2. On logout, add the jti to Redis with a TTL matching the token's remaining lifetime.
  3. On verification, check Redis for the jti. If found, reject the token.
const jwt = require('jsonwebtoken');
const { createClient } = require('redis');
const { v4: uuidv4 } = require('uuid');

const redis = createClient();

// Issue token with unique jti
function issueToken(userId) {
  const token = jwt.sign(
    {
      sub: userId,
      jti: uuidv4(),
    },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  );

  return token;
}

// Logout — add jti to deny list
async function logout(token) {
  const payload = jwt.decode(token);
  const ttl = payload.exp - Math.floor(Date.now() / 1000);

  if (ttl > 0) {
    // Store jti in Redis with TTL matching token expiration
    await redis.set(`deny:${payload.jti}`, '1', { EX: ttl });
  }
}

// Verify — check deny list
async function verifyToken(token) {
  try {
    const payload = jwt.verify(token, ACCESS_SECRET);

    // Check if jti is denied
    const denied = await redis.get(`deny:${payload.jti}`);
    if (denied) {
      throw new Error('Token has been revoked');
    }

    return payload;
  } catch (err) {
    throw err;
  }
}

Pros and Cons

  • Pro: Selective per-token revocation. Log out one device without affecting others.
  • Pro: Memory bounded by token lifetime. A token that expires is automatically removed from Redis.
  • Pro: No database schema changes needed.
  • Con: Every request needs a Redis round-trip (~1ms).
  • Con: Redis must be available. If Redis goes down, you lose the deny list.
  • Con: Memory scales with active token volume. At 1 million concurrent sessions with 15-minute tokens, that is ~1 million keys in Redis (roughly 100MB).

Strategy 4: Token Expiration Claim Override (Rolling Window)

Instead of a deny list, store a minimum allowed iat (issued-at) timestamp per user. Any token with iat before that threshold is rejected.

This is a compromise between the version approach and the deny list approach.

// Store the minimum allowed iat per user in Redis
async function setMinimumIat(userId) {
  const now = Math.floor(Date.now() / 1000);
  await redis.set(`min_iat:${userId}`, now);
}

// Logout — update minimum iat
async function logoutUser(userId) {
  await setMinimumIat(userId);
}

// Verify — check token's iat against minimum
async function verifyToken(token) {
  try {
    const payload = jwt.verify(token, ACCESS_SECRET);

    const minIat = await redis.get(`min_iat:${payload.sub}`);
    if (minIat && payload.iat < parseInt(minIat)) {
      throw new Error('Token issued before logout');
    }

    return payload;
  } catch (err) {
    throw err;
  }
}

Pros and Cons

  • Pro: One Redis key per user, regardless of how many sessions they have.
  • Pro: Logs out all sessions for a user — good for password changes.
  • Pro: Simple to implement and reason about.
  • Con: Cannot invalidate a single session.
  • Con: Requires iat claim in every token (standard but make sure it is set).

Strategy 5: Public Key Rotation (Nuclear Option)

For RS256-signed tokens, rotate the key pair on logout. Old tokens signed with the old key become invalid. This is drastic but effective.

const NodeRSA = require('node-rsa');
const jwt = require('jsonwebtoken');

// Key store — could be files, a database, or a secrets manager
const keyStore = {
  current: null,
  previous: null,
};

function generateKeyPair() {
  const key = new NodeRSA({ b: 2048 });
  return {
    private: key.exportKey('private'),
    public: key.exportKey('public'),
  };
}

async function rotateKeys() {
  keyStore.previous = keyStore.current;
  keyStore.current = generateKeyPair();

  // You might keep the previous key for a grace period
  // to allow in-flight tokens to expire naturally
}

function issueToken(userId) {
  // Sign with current private key
  return jwt.sign({ sub: userId }, keyStore.current.private, {
    algorithm: 'RS256',
    expiresIn: '15m',
  });
}

function verifyToken(token) {
  // Try current key first, then previous key
  try {
    return jwt.verify(token, keyStore.current.public, { algorithms: ['RS256'] });
  } catch {
    if (keyStore.previous) {
      return jwt.verify(token, keyStore.previous.public, { algorithms: ['RS256'] });
    }
    throw new Error('Token invalid');
  }
}

Pros and Cons

  • Pro: Complete invalidation. Every token that was signed with the old key is instantly dead.
  • Pro: No database or Redis dependency. Pure cryptography.
  • Con: Rotating keys invalidates EVERYONE's tokens. Use this for emergency situations only.
  • Con: Distributed systems need a way to propagate the new public key. Without shared storage, each node needs to know about both keys.

Which Strategy Should You Use?

Here is how I make the decision:

ScenarioRecommended Strategy
Low-security app, MVP, prototypesShort expiration only (Strategy 1)
Password changes require session invalidationToken version + cache (Strategy 2)
Selective per-device logout neededDeny list in Redis (Strategy 3)
High-volume API, minimize dependenciesRolling window iat (Strategy 4)
Security breach, emergency key rotationPublic key rotation (Strategy 5)

Most production apps combine Strategy 1 (short expiration) with one of the others. Short expiration limits the damage window regardless of what else you implement.


Common Implementation Mistakes

Mistake 1: Storing the Deny List in the Wrong Place

A database-backed deny list destroys the performance advantage of JWTs. If you query PostgreSQL on every request, you might as well use server-side sessions. Use Redis or another in-memory store with automatic TTL.

Mistake 2: Forgetting Cache Consistency

If you use the token version strategy (Strategy 2) without a cache, every request hits the database. If you cache without TTL, logged-out users can still access resources until the cache expires. Use short cache TTLs (30–60 seconds) and accept the slight delay in propagation.

Mistake 3: Not Including jti in Every Token

Skipping the jti claim makes per-token revocation impossible. Add it from day one — it is a one-line change in your token issuance code and costs nothing.

// Always include jti
const token = jwt.sign(
  {
    sub: user.id,
    jti: crypto.randomUUID(),
  },
  secret,
  { expiresIn: '15m' }
);

Mistake 4: Logging Out Without Invalidating Refresh Tokens

Invalidating the access token is only half the job. The refresh token — which can issue new access tokens — must also be revoked. See JWT Access Token vs Refresh Token Guide for the complete picture.

Mistake 5: Relying on Client-Side Token Deletion

Deleting the token from localStorage is not "logging out." It is "hiding the token from the UI." The token still exists in memory, in browser extensions, in server logs, and potentially in the hands of an attacker. Server-side invalidation is the only real logout.

// BAD: Client-side only "logout"
function logout() {
  localStorage.removeItem('jwt');
  window.location.href = '/login';
}

// GOOD: Server-side invalidation
async function logout() {
  await fetch('/api/auth/logout', { method: 'POST' });
  localStorage.removeItem('jwt');
  window.location.href = '/login';
}

Implementation Quick Reference

Here is a complete Express middleware that combines short expiration with a Redis deny list:

const jwt = require('jsonwebtoken');
const { createClient } = require('redis');

const redis = createClient();

// Auth middleware
async function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }

  const token = authHeader.split(' ')[1];

  try {
    // Verify signature and expiration first (fast, no I/O)
    const payload = jwt.verify(token, process.env.JWT_SECRET);

    // Check deny list in Redis (fast, async)
    const denied = await redis.get(`deny:${payload.jti}`);
    if (denied) {
      return res.status(401).json({ error: 'Token revoked' });
    }

    req.user = payload;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// Logout endpoint
app.post('/api/auth/logout', authMiddleware, async (req, res) => {
  const token = req.headers.authorization.split(' ')[1];
  const payload = jwt.decode(token);
  const ttl = payload.exp - Math.floor(Date.now() / 1000);

  if (ttl > 0) {
    await redis.set(`deny:${payload.jti}`, '1', { EX: ttl });
  }

  // Also revoke the refresh token (if using one)
  // await refreshTokenService.revokeAllForUser(req.user.sub);

  res.json({ message: 'Logged out' });
});

FAQ

Can you invalidate a JWT without a database?

Yes, using several strategies: short expiration times, token version claims (stored in user record), deny lists in Redis, or rolling window iat checks. None require a per-request database query if you cache properly.

Is it possible to revoke a JWT that has no jti?

No. Without a unique identifier in every token, you cannot selectively revoke individual tokens. Your options are limited to user-level invalidation (token version) or key rotation. Always include a jti claim.

Does deleting the JWT from the client count as logout?

No. Server-side invalidation is required. The token could be stored in browser extensions, cached in service workers, or captured by malware. Only the server can truly log out a user.

Should I use a database for the JWT deny list?

Only if you already query a database on every authenticated request. If you are using JWTs for their stateless performance advantage, a database deny list eliminates that advantage. Use Redis or another in-memory store.

How long should I keep tokens in the deny list?

The token's remaining lifetime. If the token expires in 10 minutes, keep it in the deny list for 10 minutes. Redis TTL handles this automatically. There is no reason to keep a revoked token in the deny list after it would have expired naturally.

What about refresh token invalidation?

Refresh tokens must be revocable by design — that is why they are stored in a database (hashed) and looked up on each refresh request. The same principles apply: rotate on use, revoke on logout, and expire after a reasonable lifetime.


The Bottom Line

Invalidating JWTs is harder than invalidating server-side sessions because stateless tokens have no server-side record to delete. But it does not require a database lookup on every request.

Pick the right strategy for your threat model: short expiration for convenience, token version for password changes, deny list for selective logout, or key rotation for emergencies. Implement it correctly and test the logout flow end-to-end.

The JWT Decoder is helpful for debugging these flows — decode a token, check its jti, iat, and exp claims, and verify that your invalidation logic is working as expected.