If your app uses JWTs and you're storing a single long-lived token, you have a security hole. A leaked token gives an attacker access for hours or days, and you can't revoke it without server-side state -- which defeats the purpose of JWTs in the first place.
Refresh token rotation solves this cleanly. Short-lived access tokens handle authorization. Long-lived refresh tokens handle re-authentication. And each refresh token can only be used once -- if it's ever reused, you know it was stolen.
Here's how to implement it properly with Fastify and Prisma.
The Token Lifecycle
The flow works like this:
- User logs in -- server issues an access token (15 min) and a refresh token (7 days)
- Access token expires -- client sends refresh token to get a new pair
- Server verifies the refresh token, invalidates it, and issues a fresh pair
- If a refresh token is used twice -- revoke the entire family
That last point is critical. If an attacker steals a refresh token and uses it before the legitimate user does, the legitimate user's next refresh attempt will fail (token already used). That failure is your signal to revoke everything.
Database Schema
You need to store refresh tokens server-side. This is the one piece of state you can't avoid:
model RefreshToken {
id String @id @default(cuid())
token String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
familyId String
usedAt DateTime?
expiresAt DateTime
createdAt DateTime @default(now())
@@index([token])
@@index([familyId])
@@index([userId])
}
The familyId groups all tokens from the same login session. When you detect reuse, you revoke every token in that family.
Token Generation
import { randomBytes } from "crypto";
import jwt from "@fastify/jwt";
const ACCESS_TOKEN_TTL = 15 * 60; // 15 minutes
const REFRESH_TOKEN_TTL = 7 * 24 * 60 * 60; // 7 days
async function generateTokenPair(
fastify: FastifyInstance,
userId: string,
familyId?: string
) {
const accessToken = fastify.jwt.sign(
{ sub: userId },
{ expiresIn: ACCESS_TOKEN_TTL }
);
const refreshToken = randomBytes(40).toString("hex");
const family = familyId ?? randomBytes(20).toString("hex");
await fastify.prisma.refreshToken.create({
data: {
token: refreshToken,
userId,
familyId: family,
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL * 1000),
},
});
return { accessToken, refreshToken, familyId: family };
}
Notice: the refresh token is an opaque random string, not a JWT. There's no reason to make it a JWT -- you're looking it up in the database anyway. A random string is simpler and leaks no information if intercepted.
The Login Route
fastify.post("/auth/login", async (request, reply) => {
const { email, password } = request.body as LoginBody;
const user = await fastify.prisma.user.findUnique({
where: { email },
});
if (!user || !(await verify(password, user.passwordHash))) {
return reply.status(401).send({ error: "Invalid credentials" });
}
const tokens = await generateTokenPair(fastify, user.id);
reply.setCookie("refreshToken", tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/auth/refresh",
maxAge: REFRESH_TOKEN_TTL,
});
return { accessToken: tokens.accessToken };
});
Two things to notice here. First, the refresh token goes in an httpOnly cookie scoped to /auth/refresh. JavaScript can't read it, and the browser only sends it to the one endpoint that needs it. Second, the access token goes in the response body -- the client stores it in memory (not localStorage).
The Refresh Route (Where Rotation Happens)
This is the core of the whole system:
fastify.post("/auth/refresh", async (request, reply) => {
const token = request.cookies.refreshToken;
if (!token) {
return reply.status(401).send({ error: "No refresh token" });
}
const stored = await fastify.prisma.refreshToken.findUnique({
where: { token },
});
// Token doesn't exist or expired
if (!stored || stored.expiresAt < new Date()) {
return reply.status(401).send({ error: "Invalid token" });
}
// REUSE DETECTED -- revoke entire family
if (stored.usedAt) {
await fastify.prisma.refreshToken.deleteMany({
where: { familyId: stored.familyId },
});
fastify.log.warn(
{ userId: stored.userId, familyId: stored.familyId },
"Refresh token reuse detected -- family revoked"
);
return reply.status(401).send({ error: "Token reuse detected" });
}
// Mark current token as used
await fastify.prisma.refreshToken.update({
where: { id: stored.id },
data: { usedAt: new Date() },
});
// Issue new pair in same family
const tokens = await generateTokenPair(
fastify,
stored.userId,
stored.familyId
);
reply.setCookie("refreshToken", tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/auth/refresh",
maxAge: REFRESH_TOKEN_TTL,
});
return { accessToken: tokens.accessToken };
});
The key check is if (stored.usedAt). If a token has already been used, someone is replaying it. You nuke the entire family and force a re-login.
Client-Side: Silent Refresh
On the frontend, you need to refresh transparently before the access token expires. A simple approach with Axios or fetch:
let accessToken: string | null = null;
async function apiCall(url: string, options: RequestInit = {}) {
if (!accessToken) {
await refreshAccessToken();
}
let response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
});
// Token expired mid-request
if (response.status === 401) {
await refreshAccessToken();
response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
});
}
return response;
}
async function refreshAccessToken() {
const res = await fetch("/auth/refresh", {
method: "POST",
credentials: "include", // sends the httpOnly cookie
});
if (!res.ok) {
accessToken = null;
window.location.href = "/login";
return;
}
const data = await res.json();
accessToken = data.accessToken;
}
The access token lives only in a variable -- it never touches localStorage or sessionStorage. On page reload, the client calls /auth/refresh once to get a fresh access token from the cookie.
Cleanup: Expired Token Pruning
Refresh tokens accumulate. Run a periodic cleanup to delete expired and used tokens:
async function pruneExpiredTokens(prisma: PrismaClient) {
const deleted = await prisma.refreshToken.deleteMany({
where: {
OR: [
{ expiresAt: { lt: new Date() } },
{
usedAt: { not: null },
createdAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) },
},
],
},
});
return deleted.count;
}
Run this on a cron job or as a Fastify plugin with setInterval. Used tokens older than 24 hours are safe to delete -- if reuse detection hasn't triggered by then, it won't.
Logout
On logout, delete all refresh tokens for the user (or just the current family if you want to preserve other sessions):
fastify.post("/auth/logout", async (request, reply) => {
const token = request.cookies.refreshToken;
if (token) {
const stored = await fastify.prisma.refreshToken.findUnique({
where: { token },
});
if (stored) {
// Revoke all sessions for this user
await fastify.prisma.refreshToken.deleteMany({
where: { userId: stored.userId },
});
}
}
reply.clearCookie("refreshToken", { path: "/auth/refresh" });
return { success: true };
});
What This Gets You
- Access tokens expire in 15 minutes -- limits the damage window of a leaked token
- Refresh tokens are single-use -- replay attacks are detected immediately
- Family-based revocation -- one suspicious reuse kills the entire session chain
- httpOnly cookies -- refresh tokens are invisible to XSS attacks
- No token in localStorage -- the most common JWT vulnerability, eliminated
The tradeoff is one database lookup per refresh (every 15 minutes per active user). For most apps, that's negligible compared to the security improvement.
If you're building auth from scratch in Node.js, this pattern gives you production-grade token management without depending on a third-party auth service. It's the same rotation strategy that OAuth 2.0 recommends in RFC 6749 Section 10.4, adapted for first-party API authentication.
Top comments (0)