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:
- Rotate immediately - Give a new refresh token every time (breaks concurrent requests)
- 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 usednextToken
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 revokedusedAt
: When this token was used (null = never used)expiresAt
: Absolute expiration timeuserId
: 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:
- Lock the row:
for("update")
prevents race conditions - Check expiration: Invalid tokens get rejected immediately
- Reuse detection: If
usedAt
exists, check if we’re in the allowed window - Within window: Return the
nextToken
(graceful handling) - Outside window: Suspicious! Delete all user tokens
- 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:
-
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
-
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
- Old token is revoked, but
Attack Scenario:
- Legitimate use: User refreshes → token gets rotated
- 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
- Old token has
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.