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

Refresh Token Rotation

The problem with long-lived access tokens

Sections 2 and 3 hardened the session-based auth: rate limiting, lockout, timing protection, and CSRF. Now we turn to the token-based auth (JWTs) that the auth course also built. The problem: the auth course issued JWTs that expire in 24 hours. If an access token is stolen, the attacker has 24 hours of access. You cannot revoke it without server-side state (which defeats the purpose of stateless tokens).

The standard solution: make access tokens short-lived and use refresh tokens to get new ones.

Two tokens, two lifetimes

Access token: A short-lived JWT (15 minutes). Used for API authentication. Sent with every request in the Authorization header. If stolen, the damage window is 15 minutes.

Refresh token: A long-lived opaque string (30 days). Used only to get a new access token. Stored in a server-side table. Can be revoked instantly (delete the record).

The flow:

1. User logs in
   → Server returns: { accessToken (15 min), refreshToken (30 days) }

2. Client makes API requests
   → Sends accessToken in Authorization header
   → Works until accessToken expires

3. Access token expires
   → Client sends refreshToken to POST /token/refresh
   → Server verifies refreshToken, issues new accessToken AND new refreshToken
   → Old refreshToken is invalidated (rotation)

4. Client uses the new accessToken for the next 15 minutes
   → Repeat step 3 when it expires

Why rotate the refresh token?

Every time the client uses a refresh token, the server issues a new one and invalidates the old one. This is rotation.

Without rotation: if an attacker steals the refresh token, they can use it to get new access tokens forever (or until it expires in 30 days). You would need to wait for the attacker to make a request and check a deny list.

With rotation: if an attacker steals the refresh token and uses it, the server issues a new pair. The legitimate client still has the old refresh token. When the legitimate client tries to use the old (now invalidated) token, the server detects reuse: the same token family has been used twice. This is a signal that the token was stolen. The server can invalidate the entire family.

The refresh token store

Create src/refresh-tokens.ts:

// src/refresh-tokens.ts
import { log } from "./logger.js";

interface RefreshTokenEntry {
  userId: string;
  family: string; // groups tokens that descend from the same login
  createdAt: number;
  used: boolean;
}

const store = new Map<string, RefreshTokenEntry>();

const MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days

export function createRefreshToken(userId: string, family?: string): string {
  const token = crypto.randomUUID();
  store.set(token, {
    userId,
    family: family ?? crypto.randomUUID(),
    createdAt: Date.now(),
    used: false,
  });
  return token;
}

export function consumeRefreshToken(token: string): { userId: string; family: string } | null {
  const entry = store.get(token);
  if (!entry) return null;

  // Expired
  if (Date.now() - entry.createdAt > MAX_AGE) {
    store.delete(token);
    return null;
  }

  // Already used — potential token theft
  if (entry.used) {
    log("refresh_token_reuse", { family: entry.family, userId: entry.userId });
    // Invalidate the entire family
    revokeFamily(entry.family);
    return null;
  }

  // Mark as used (not deleted — we keep it to detect reuse)
  entry.used = true;

  return { userId: entry.userId, family: entry.family };
}

export function revokeFamily(family: string): void {
  for (const [token, entry] of store) {
    if (entry.family === family) {
      store.delete(token);
    }
  }
}

export function revokeUserTokens(userId: string): void {
  for (const [token, entry] of store) {
    if (entry.userId === userId) {
      store.delete(token);
    }
  }
}

Key design choices:

Family tracking: All refresh tokens from a single login share a family ID. When the token rotates, the new token inherits the family. If reuse is detected, the entire family is invalidated.

Used flag: When a refresh token is consumed, it is marked as used but not deleted. If someone tries to use it again (the original user or an attacker), we detect the reuse.

Reuse detection: If a used token is presented again, someone has a copy of a consumed token. Either the legitimate user or the attacker is behind. We cannot tell who, so we invalidate the entire family, forcing everyone to re-authenticate.

The refresh route

Update src/routes/token-auth.ts:

import { createRefreshToken, consumeRefreshToken } from "../refresh-tokens.js";
import { createToken } from "../jwt.js";
import { users } from "../db.js";

route.post("/token/refresh", {
  resolve: async (c) => {
    const body = await c.request.json().catch(() => null);
    const refreshToken = (body as any)?.refreshToken;

    if (!refreshToken || typeof refreshToken !== "string") {
      return Response.json({ error: "Missing refresh token" }, { status: 400 });
    }

    const result = consumeRefreshToken(refreshToken);
    if (!result) {
      return Response.json({ error: "Invalid refresh token" }, { status: 401 });
    }

    // Issue a new pair
    const user = users.get(result.userId);
    if (!user) {
      return Response.json({ error: "User not found" }, { status: 401 });
    }

    const accessToken = await createToken({
      userId: user.id,
      email: user.email,
      role: user.role,
    });

    const newRefreshToken = createRefreshToken(result.userId, result.family);

    return Response.json({ accessToken, refreshToken: newRefreshToken });
  },
});

Update the login route to issue both tokens

Update the token login to return both an access token and a refresh token:

route.post("/token/login", {
  resolve: async (c) => {
    // ... validate, look up user, verify password ...

    const accessToken = await createToken({
      userId: user.id,
      email: user.email,
      role: user.role,
    });

    const refreshToken = createRefreshToken(user.id);

    return Response.json({ accessToken, refreshToken });
  },
});

Update access token expiry

In src/jwt.ts, change the access token lifetime from 24 hours to 15 minutes:

.setExpirationTime("15m")

Exercises

Exercise 1: Log in and get both tokens. Use the access token for a protected route. Wait 15 minutes (or temporarily set expiry to "5s") and try again. The access token should fail. Use the refresh token at POST /token/refresh to get a new pair. Use the new access token and verify it works.

Exercise 2: Use a refresh token twice. The first call should succeed. The second call should fail with 401 and log a refresh_token_reuse event. Check that all tokens in the family are invalidated.

Exercise 3: Log in twice (two separate sessions). Each login gets its own refresh token with a different family. Reuse one refresh token. Only that family should be invalidated. The other login’s refresh token should still work.

What happens when a consumed (already-used) refresh token is presented again?

Why do we use 15-minute access tokens instead of 24-hour tokens?

← CSRF for API Consumers Token Revocation →

© 2026 hectoday. All rights reserved.