The Problem with Standard JWTs

Many Node.js tutorials teach a simple JWT authentication flow: sign a token on login, send it to the frontend, store it in localStorage, and attach it to subsequent requests. This approach is fundamentally flawed because JWTs are immutable once signed. They remain valid until their expiration date, regardless of whether the user changes their password or an administrator attempts to invalidate the session. This means if a token is compromised or a user needs to be logged out immediately, there's no mechanism to revoke that specific token without changing the signing secret—an action that logs out all users.

Consider a scenario where a user's account is compromised. The attacker gains access to a valid JWT. Even if the user immediately changes their password, the attacker's token remains active until it expires. This leaves the system vulnerable. The core issue is the lack of a server-side session management system that can track and invalidate active tokens.

Building Revocable JWT Authentication

To achieve revocable JWT authentication, we need to augment the standard JWT approach with a server-side mechanism. This typically involves using JWTs as access tokens but managing their lifecycle and validity outside the token itself.

Refresh Token Rotation

A common and effective strategy is implementing refresh token rotation. Instead of issuing a single long-lived refresh token, the system issues a new refresh token (and a new access token) each time the refresh token is used. The old refresh token is invalidated upon successful use, and a record of the currently valid refresh token is stored server-side.

When a user logs in, the server issues:

  • A short-lived access token (e.g., 15 minutes).
  • A longer-lived refresh token (e.g., 7 days).

The refresh token is stored securely on the client (e.g., in an HttpOnly cookie) and a corresponding entry is created in a server-side data store (like Redis or a database) linking the refresh token's identifier to the user ID and its expiration. This server-side record is crucial for revocation.

When the access token expires, the client uses the refresh token to request a new access token. The server checks if the provided refresh token exists and is valid in its data store. If valid, the server invalidates the *old* refresh token, generates a new refresh token, stores its identifier, and returns both the new access token and the new refresh token to the client. If the provided refresh token is not found or is invalid, the user is logged out.

Diagram illustrating the refresh token rotation flow between client and server.

Revocation with a Denylist

A more direct method for revocation, especially useful for immediate invalidation, is employing a denylist (or blocklist). When a user logs out, or an administrator revokes a session, the identifier of the JWT (e.g., the jti claim) is added to a server-side denylist. This denylist could be a Redis set or a database table.

Before processing any request with a JWT, the server checks if the token's identifier exists in the denylist. If it does, the token is considered invalid, and the request is rejected. This provides immediate revocation capabilities.

However, denylists can grow large and require constant checking. To manage this, tokens are typically added to the denylist with an expiration time matching their original JWT expiration. This way, old, revoked tokens are automatically removed from the denylist over time, preventing it from growing indefinitely.

Implementing in Express

Let's consider an Express.js implementation. We'll use libraries like jsonwebtoken for signing and verifying tokens, and potentially redis for storing refresh token identifiers and the denylist.

Login and Token Issuance

On successful login, generate an access token and a refresh token. Store the refresh token's ID (jti claim) and user ID in Redis, with an expiry matching the refresh token's lifespan.

const jwt = require('jsonwebtoken');
const redisClient = require('./redisClient'); // Your Redis client instance

const accessTokenSecret = process.env.ACCESS_TOKEN_SECRET;
const refreshTokenSecret = process.env.REFRESH_TOKEN_SECRET;

const issueTokens = (user) => {
  const userPayload = { id: user.id, email: user.email };

  const accessToken = jwt.sign(userPayload, accessTokenSecret, { expiresIn: '15m' });

  const refreshTokenId = jwt.sign({ userId: user.id }, refreshTokenSecret, { expiresIn: '7d', jwtid: uuidv4() }); // Generate a unique ID for the refresh token
  const refreshTokenPayload = jwt.verify(refreshTokenId, refreshTokenSecret); // Extract payload to get jti

  // Store refresh token identifier in Redis
  redisClient.set(`refresh:${refreshTokenPayload.jti}`, user.id, 'EX', 7 * 24 * 60 * 60); // Store user ID keyed by refresh token jti

  return { accessToken, refreshToken: refreshTokenId };
};

Access Token Verification Middleware

This middleware verifies the access token and checks the denylist for revoked tokens.

const verifyAccessToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) return res.sendStatus(401);

  jwt.verify(token, accessTokenSecret, async (err, user) => {
    if (err) return res.sendStatus(403);

    // Check if the token ID is in the denylist
    const isRevoked = await redisClient.get(`denylist:${jwt.decode(token).jti}`);
    if (isRevoked) return res.sendStatus(403);

    req.user = user;
    next();
  });
};

Refresh Token Endpoint

This endpoint handles requests for new access tokens using a valid refresh token.


app.post('/refresh', async (req, res) => {
  const refreshToken = req.body.token;
  if (!refreshToken) return res.sendStatus(401);

  try {
    const decoded = jwt.verify(refreshToken, refreshTokenSecret);
    const userId = await redisClient.get(`refresh:${decoded.jti}`);

    if (!userId) return res.sendStatus(403);

    // Invalidate the old refresh token by deleting it from Redis
    await redisClient.del(`refresh:${decoded.jti}`);

    // Issue new tokens
    const user = { id: userId }; // Fetch user details if needed for payload
    const { accessToken, refreshToken: newRefreshToken } = issueTokens(user);

    res.json({ accessToken, refreshToken: newRefreshToken });
  } catch (err) {
    res.sendStatus(403);
  }
});

Logout Endpoint

This endpoint revokes the current session by adding the access token's JTI to the denylist and potentially deleting the refresh token record.


app.post('/logout', verifyAccessToken, async (req, res) => {
  const token = req.headers['authorization'].split(' ')[1];
  const decoded = jwt.decode(token);

  // Add token JTI to denylist with its original expiry
  const expiry = decoded.exp - Math.floor(Date.now() / 1000);
  await redisClient.set(`denylist:${decoded.jti}`, 'revoked', 'EX', expiry);

  // Optionally, invalidate the associated refresh token if available/needed
  // This would require passing the refresh token or associating it more directly

  res.sendStatus(200);
});

The Unanswered Question: Scalability of Denylists

While denylists provide immediate revocation, their scalability is a concern for very high-traffic applications. Constantly checking a potentially large, distributed denylist can introduce latency. The strategy of setting expirations on denylist entries helps, but managing the lifecycle of these entries efficiently across distributed systems requires careful consideration. The optimal balance between immediate revocation and performance remains an active design challenge.

Beyond Basic Tutorials

Most Node.js JWT tutorials stop at the point of signing and verifying tokens. They omit the critical aspects of session management, including how to handle token expiration gracefully, how to invalidate sessions proactively, and how to protect against token theft. Implementing refresh token rotation and a denylist mechanism addresses these gaps, offering a more robust and secure authentication system for Express applications. If you're building an application where session control is important, moving beyond basic JWTs is not optional—it's essential.