hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Securing Your API with @hectoday/http

The Threat Landscape

  • What Could Go Wrong
  • Project Setup

Brute-Force Protection

  • Rate Limiting Login Attempts
  • Account Lockout
  • Timing Attack Prevention

CSRF Protection

  • What Is CSRF?
  • CSRF Tokens
  • CSRF for API Consumers

Token Hardening

  • Refresh Token Rotation
  • Token Revocation
  • Secure Token Storage

Password Reset

  • The Password Reset Flow
  • Building the Reset Routes
  • Reset Security

Putting It All Together

  • Security Headers
  • Logging and Monitoring
  • Security Checklist
  • Capstone: Hardened Auth API

Token Revocation

The revocation problem

Refresh tokens are revocable because they live in a server-side store. Delete the record, and the token is gone.

Access tokens are not revocable by default. They are stateless JWTs. The server verifies the signature and checks the expiration — it does not look up anything. With 15-minute tokens (from the previous lesson), the exposure window is small, but sometimes you need to revoke an access token immediately: a user reports their account compromised, an admin bans a user, or a password changes.

The access token deny list

A deny list is a set of token identifiers that the server checks on every request. If the token’s ID is in the deny list, it is rejected even if the signature and expiration are valid.

Add a token ID to your JWTs

First, include a unique ID (jti — JWT ID) in every access token. Update src/jwt.ts:

export async function createToken(payload: {
  userId: string;
  email: string;
  role: string;
}): Promise<string> {
  const jti = crypto.randomUUID();

  const token = await new SignJWT({ ...payload, jti })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .sign(secret);

  return token;
}

The jti claim gives each token a unique identifier.

The deny list

Create src/token-deny-list.ts:

// src/token-deny-list.ts
interface DenyEntry {
  expiresAt: number;
}

const denyList = new Map<string, DenyEntry>();

export function denyToken(jti: string, expiresAt: number): void {
  denyList.set(jti, { expiresAt });
}

export function isDenied(jti: string): boolean {
  return denyList.has(jti);
}

// Clean up expired entries every 5 minutes
setInterval(
  () => {
    const now = Date.now();
    for (const [jti, entry] of denyList) {
      if (now > entry.expiresAt) {
        denyList.delete(jti);
      }
    }
  },
  5 * 60 * 1000,
);

When we deny a token, we store its jti along with its expiration time. After the token would have expired naturally, we remove it from the deny list (it is no longer dangerous).

Check the deny list during authentication

Update verifyToken in src/jwt.ts:

import { isDenied } from "./token-deny-list.js";

export async function verifyToken(
  token: string,
): Promise<{ userId: string; email: string; role: string; jti: string } | null> {
  try {
    const { payload } = await jwtVerify(token, secret);

    if (
      typeof payload.userId !== "string" ||
      typeof payload.email !== "string" ||
      typeof payload.role !== "string" ||
      typeof payload.jti !== "string"
    ) {
      return null;
    }

    // Check the deny list
    if (isDenied(payload.jti)) {
      return null;
    }

    return {
      userId: payload.userId,
      email: payload.email,
      role: payload.role,
      jti: payload.jti,
    };
  } catch {
    return null;
  }
}

Now every token verification checks the deny list. If the token’s jti has been denied, authentication fails.

Revoking on password change

When a user changes their password, revoke all their tokens and sessions:

import { denyToken } from "./token-deny-list.js";
import { revokeUserTokens } from "./refresh-tokens.js";
import { deleteUserSessions } from "./sessions.js";

function revokeAllUserAuth(userId: string): void {
  // Revoke refresh tokens (server-side, immediate)
  revokeUserTokens(userId);

  // Delete sessions (server-side, immediate)
  deleteUserSessions(userId);

  // For access tokens: we cannot enumerate active JWTs
  // They expire in 15 minutes. This is the accepted tradeoff.
}

Notice we cannot deny all active access tokens for a user because we do not track which tokens belong to which user. The access tokens expire in 15 minutes, which is the accepted tradeoff of short-lived tokens. For truly immediate revocation, you would need to track all issued jti values per user (which adds server-side state).

When to deny individual tokens

Individual token denial is useful when:

  • An admin bans a specific user mid-session (deny their current token’s jti)
  • A user explicitly logs out of a specific device (the logout route can deny the token)
  • Suspicious activity is detected for a specific request

The cost of deny lists

Checking the deny list adds a Map lookup to every authenticated request. This is fast (O(1) for a Map), but it reintroduces server-side state, which was the advantage of stateless JWTs.

In practice, this is an acceptable tradeoff. The deny list is small (only recently revoked tokens), lookups are fast, and the security benefit is significant. Most production systems that use JWTs have some form of deny list.

Exercises

Exercise 1: Add the jti claim to your access tokens. Log in, decode the token, and verify the jti field is present.

Exercise 2: Manually deny a token’s jti and verify the next request with that token returns 401 even though the signature and expiration are still valid.

Exercise 3: What happens to the deny list if the server restarts? (It is lost — all denied tokens become valid again.) For the 15-minute window of access tokens, this is a minor risk. In production, use Redis for the deny list so it survives restarts.

Why do deny list entries include an expiration time?

← Refresh Token Rotation Secure Token Storage →

© 2026 hectoday. All rights reserved.