DEV Community

DevForge Templates
DevForge Templates

Posted on

JWT Refresh Token Rotation in Node.js: The Complete Implementation

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:

  1. User logs in -- server issues an access token (15 min) and a refresh token (7 days)
  2. Access token expires -- client sends refresh token to get a new pair
  3. Server verifies the refresh token, invalidates it, and issues a fresh pair
  4. 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])
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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 };
});
Enter fullscreen mode Exit fullscreen mode

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 };
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 };
});
Enter fullscreen mode Exit fullscreen mode

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)