Back to blog
Secure Refresh Token Rotation with Theft Detection
5 min read

Hello 👋! In this blog, I’ll show you how to implement a secure refresh token system that handles reuse detection and time-based windows using Drizzle ORM. This approach prevents token theft attacks while still supporting legitimate concurrent requests.

Last time I wrote a blog, I would have just rotated refresh tokens immediately on every use. But that breaks things when your app makes multiple requests at the same time and you need to implement a queue in your frontend to retry the requests like shown here.

The Problem

When someone steals your refresh token, they can use it to get new access tokens and mess with your API. The usual solutions are:

  1. Rotate immediately - Give a new refresh token every time (breaks concurrent requests)
  2. Never rotate - Keep the same token forever (might get you in trouble)

Neither of these work well in practice.

The Solution: Token Rotation with Reuse Detection

Here’s what we’ll implement:

  • Immediate token rotation on first use (more secure than time windows)
  • usedAt timestamp tracking to know when tokens were used
  • nextToken reference to the replacement token during reuse window
  • Reuse detection with automatic session revocation
  • Database transactions to prevent race conditions

The key insight: instead of allowing the same token for 10 seconds, we immediately rotate but allow the replacement token to be returned if someone tries to reuse the old one within the allowed window.

Let’s dive into the code.

Step 1: Database Schema with Drizzle

First, let’s define our refresh tokens table using Drizzle ORM.

import { pgTable, varchar, timestamp, uuid } from "drizzle-orm/pg-core";

export const refreshTokens = pgTable("refresh_tokens", {
  token: varchar("token", { length: 256 }).primaryKey(),
  nextToken: varchar("next_token", { length: 256 }).unique(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at")
    .notNull()
    .defaultNow()
    .$onUpdate(() => new Date()),
  userId: uuid("user_id")
    .notNull()
    .references(() => users.id, {
      onDelete: "cascade",
    }),
  expiresAt: timestamp("expires_at").notNull(),
  usedAt: timestamp("used_at"), // Key field for tracking usage
});

Key fields explained:

  • token: The current refresh token (primary key)
  • nextToken: The replacement token when this one gets revoked
  • usedAt: When this token was used (null = never used)
  • expiresAt: Absolute expiration time
  • userId: Which user this token belongs to

Step 2: Environment Configuration

Set up your environment variables for the reuse detection:

# Token lifetimes
ACCESS_TOKEN_TTL=3600          # 1 hour
REFRESH_TOKEN_TTL=5184000      # 60 days

# Security settings
REUSE_INTERVAL_SECONDS=10      # Allow reuse for 10 seconds
JWT_SECRET=your-super-secret-key

Step 3: The Core Refresh Logic

Here’s the complete implementation of our secure refresh token system. This function handles all the complexity of rotation and reuse detection:

import { eq } from "drizzle-orm";
import { db } from "./db";
import { refreshTokens } from "./schema";

export async function refreshAccessToken(incomingToken: string) {
  const [tokens, error] = await db.transaction(async (tx) => {
    // Lock the row to prevent race conditions
    const [current] = await tx
      .select()
      .from(refreshTokens)
      .where(eq(refreshTokens.token, incomingToken))
      .for("update");

    if (!current) {
      return [null, new ForbiddenError("Invalid refresh token")];
    }

    const now = new Date();

    // Check if token has expired
    if (current.expiresAt < now) {
      return [null, new ForbiddenError("Refresh token expired")];
    }

    // Already used? → reuse check
    if (current.usedAt) {
      const ageSeconds = (now.getTime() - current.usedAt.getTime()) / 1000;

      if (ageSeconds <= config.api.reuseIntervalSeconds && current.nextToken) {
        // Within interval → return same replacement token
        return [
          {
            accessToken: makeJWT(current.userId, 3600, config.api.jwtSecret),
            refreshToken: current.nextToken,
          },
          null,
        ];
      }

      // Outside interval → suspicious reuse → revoke all sessions
      await tx
        .delete(refreshTokens)
        .where(eq(refreshTokens.userId, current.userId));

      return [
        null,
        new ForbiddenError("Token reuse detected — all sessions revoked"),
      ];
    }

    // Token unused → rotate immediately
    const newToken = makeRefreshToken();

    // Create the new token first
    const [newRow] = await tx
      .insert(refreshTokens)
      .values({
        token: newToken,
        userId: current.userId,
        usedAt: null,
        nextToken: null,
        expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 60), // 60 days
      })
      .returning();

    // Mark the old token as revoked and link to new token
    await tx
      .update(refreshTokens)
      .set({
        usedAt: now,
        nextToken: newRow.token,
        updatedAt: now,
      })
      .where(eq(refreshTokens.token, current.token));

    return [
      {
        accessToken: makeJWT(current.userId, 3600, config.api.jwtSecret),
        refreshToken: newToken,
      },
      null,
    ];
  });

  if (error) {
    throw error;
  }

  return tokens;
}

How this works:

  1. Lock the row: for("update") prevents race conditions
  2. Check expiration: Invalid tokens get rejected immediately
  3. Reuse detection: If usedAt exists, check if we’re in the allowed window
  4. Within window: Return the nextToken (graceful handling)
  5. Outside window: Suspicious! Delete all user tokens
  6. First use: Create new token, mark current as revoked

Step 4: Initial Token Creation

When a user first logs in, create their initial refresh token:

async function createInitialRefreshToken(userId: string): Promise<string> {
  const token = makeRefreshToken();

  await db.insert(refreshTokens).values({
    token,
    userId,
    nextToken: null,
    usedAt: null,
    expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 60), // 60 days
  });

  return token;
}

// Usage in your login endpoint
async function loginUser(email: string, password: string) {
  // ... validate credentials ...

  const refreshToken = await createInitialRefreshToken(user.id);
  const accessToken = makeJWT(user.id, 3600, config.api.jwtSecret);

  return {
    accessToken,
    refreshToken,
    expiresIn: 3600,
  };
}

How it works

Normal Usage Flow:

  1. First use: Token is fresh (usedAt is null)

    • Create new token immediately
    • Mark old token as revoked, set nextToken to new token
    • Return new token to client
  2. Concurrent requests: Multiple requests with the same old token

    • Old token is revoked, but nextToken exists
    • Check if within reuse window (10 seconds)
    • Return the nextToken to all concurrent requests

Attack Scenario:

  1. Legitimate use: User refreshes → token gets rotated
  2. Attacker tries old token: 70 seconds later (outside window)
    • Old token has usedAt timestamp
    • 70 seconds > 10 seconds = suspicious reuse
    • All user sessions get revoked immediately

Conclusion

That’s it! You now have a production-ready refresh token system that’s both highly secure and handles real-world edge cases like concurrent requests.