- Auth
- Security
- NestJS
- Backend
Refresh Tokens in Next.js, Part 3: Rotation and Theft Detection on the Backend
How a NestJS API issues refresh tokens, rotates them on every use, catches stolen ones, and ends sessions instantly — with Prisma and MongoDB.
This is part 3 of a three-part series. Part 1 explains the problem. Part 2 covers the Next.js side. This part covers the backend.
The frontend from part 2 only forwards tokens. Every security decision happens here, on the backend: who gets a token, when it stops working, and what to do when one is stolen.
The code is from the demo repo (apps/api): NestJS, Prisma and MongoDB.
The two tokens, as the backend sees them
- Access token: a signed JWT that lasts 60 seconds. The backend can check it with just the secret key, no database needed.
- Refresh token: a random string that lasts 7 days, stored in the database.
Why isn't the refresh token a JWT as well? A JWT's advantage is that you can trust it without a database lookup. But the refresh token must always be looked up, because that's the only way to cancel it. Once you're looking it up anyway, a signature adds nothing. A random string is simpler.
The data model
model RefreshToken {
id String @id @default(auto()) @map("_id") @db.ObjectId
token String @unique
familyId String
used Boolean @default(false)
usedAt DateTime?
replacedByToken String?
expiresAt DateTime
userId String @db.ObjectId
@@index([familyId])
}Two fields need explaining:
familyIdgroups every refresh token that came from one login. Ending a session means deleting its whole family.used/replacedByTokenrecord that a token has been swapped, and for which new token. That's what makes theft detection possible.
The User model also has a sessionId field, which holds the family id of the user's current session. We'll use it at the end.
Login: start a new session
On login, the backend creates a new family, saves the first refresh token, and signs an access token:
// apps/api/src/auth/auth.service.ts
private async createExclusiveSession(user: User): Promise<TokenPair> {
const familyId = crypto.randomUUID();
const refresh = await this.sessions.startExclusiveSession(
user.id,
familyId,
refreshTokenTtlSeconds(),
);
return {
accessToken: await this.signAccessToken(user, refresh.familyId),
refreshToken: refresh.token,
};
}// apps/api/src/auth/repositories/session.repository.ts
async startExclusiveSession(userId: string, familyId: string, refreshTtlSeconds: number) {
const [, , refreshToken] = await this.prisma.$transaction([
this.prisma.refreshToken.deleteMany({ where: { userId } }),
this.prisma.user.update({
where: { id: userId },
data: { sessionId: familyId },
}),
this.prisma.refreshToken.create({
data: {
token: `${crypto.randomUUID()}.${crypto.randomUUID()}`,
familyId,
userId,
expiresAt: new Date(Date.now() + refreshTtlSeconds * 1000),
},
}),
]);
return refreshToken;
}The demo allows one active device per user: logging in deletes the user's old refresh tokens. If you want several devices, skip the deleteMany and keep one family per device.
The access token carries the family id as sid:
// Simplified: the real version also adds the mobile number and logs the expiry.
private async signAccessToken(user: User, sessionId: string) {
return new SignJWT({ email: user.email, name: user.name, sid: sessionId })
.setProtectedHeader({ alg: "HS256" })
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime(`${accessTokenTtlSeconds()}s`)
.sign(this.jwtSecret());
}Refresh: every token works only once
This is the heart of the backend. A refresh token can be used once. Using it returns a new access token and a new refresh token, and the old one is marked as used.
Why bother? Think about a stolen refresh token. Now two people hold the same token: the real user and the attacker. Whoever uses it first gets a fresh one. When the other one tries, the server sees a token that was already used. An honest client never does that, so it must be a copy.
The server can't tell which of the two is the attacker — so it ends the session for both. The real user just logs in again. The attacker is locked out for good.
Here's the refresh logic:
private async rotateRefreshToken(record: RefreshToken): Promise<TokenPair> {
if (record.used) {
return this.resolveUsedRefreshToken(record);
}
if (record.expiresAt.getTime() <= Date.now()) {
await this.sessions.revokeSession(record.userId, record.familyId);
throw new UnauthorizedException("Refresh token expired");
}
const candidate = await this.refreshTokens.issue(
record.userId,
record.familyId,
refreshTokenTtlSeconds(),
);
const claimed = await this.refreshTokens.claim(record.id, candidate.token);
if (!claimed) {
// Another request rotated this token first.
await this.refreshTokens.deleteById(candidate.id);
const claimedRecord = await this.refreshTokens.findById(record.id);
if (!claimedRecord) {
throw new UnauthorizedException("Session is no longer active");
}
return this.resolveUsedRefreshToken(claimedRecord);
}
const user = await this.users.findById(record.userId);
if (!user || user.sessionId !== record.familyId) {
await this.sessions.revokeSession(record.userId, record.familyId);
throw new UnauthorizedException("Session is no longer active");
}
return {
accessToken: await this.signAccessToken(user, record.familyId),
refreshToken: candidate.token,
};
}In plain words: if the token was already used, go to the reuse check. If it's expired, end the session. Otherwise create the replacement, mark the old one as used, and return the new pair.
Make "mark as used" a single step
There's a subtle bug waiting in the obvious version:
- Request A reads the token:
used = false. - Request B reads the token:
used = false. - Both mark it used, and both get a valid new token.
Now one refresh token has turned into two, and rotation is broken. The fix is to check and update in one database operation, so only one request can win:
// apps/api/src/auth/repositories/refresh-token.repository.ts
async claim(id: string, replacedByToken: string): Promise<boolean> {
const claimed = await this.prisma.refreshToken.updateMany({
where: { id, used: false },
data: { used: true, usedAt: new Date(), replacedByToken },
});
return claimed.count === 1;
}The where: { used: false } is the important part. The database only updates the row if it's still unused, and count tells us whether we were the one who changed it.
Don't punish two open tabs
Strict "use once" has one problem with real users. Open the app in two tabs, wait for the access token to expire, and both tabs refresh at the same moment with the same token. One wins. The other sends a used token — which looks exactly like theft — and the user is logged out for doing nothing wrong.
So the demo allows a 10-second grace period. If a used token comes back within 10 seconds, the server returns the replacement it already created. After that, it's treated as theft:
private async resolveUsedRefreshToken(record: RefreshToken): Promise<TokenPair> {
const withinGrace =
record.usedAt !== null &&
Date.now() - record.usedAt.getTime() <= REFRESH_GRACE_MS;
if (withinGrace && record.replacedByToken) {
// ...return the replacement token if it's still valid
}
await this.sessions.revokeSession(record.userId, record.familyId);
throw new UnauthorizedException({
error: "Refresh token reuse detected; session revoked",
code: "REUSE_DETECTED",
});
}This is a trade-off: a thief who replays a token within those 10 seconds won't be caught. But the window is short, and without it real users get logged out regularly.
Ending a session
Ending a session means deleting the whole family and clearing the user's sessionId, in one transaction:
async revokeSession(userId: string, familyId: string): Promise<void> {
await this.prisma.$transaction([
this.prisma.refreshToken.deleteMany({ where: { familyId } }),
this.prisma.user.updateMany({
where: { id: userId, sessionId: familyId },
data: { sessionId: null },
}),
]);
}The same function is used for logout, expired refresh tokens, and detected theft.
Making access tokens stop at once
There's one gap left. Deleting the refresh tokens stops future refreshes, but an access token that was already issued would keep working until it expires — up to 60 seconds.
That's why the access token carries sid. The guard that protects each endpoint verifies the JWT, then checks that sid still matches the user's current sessionId:
// apps/api/src/auth/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const header = request.headers.authorization;
if (!header?.toLowerCase().startsWith("bearer ")) {
throw new UnauthorizedException("Access token missing");
}
const token = header.slice(7).trim();
request.user = await this.authService.getProfile(token);
request.accessToken = token;
return true;
}
}After logout or a revoke, sessionId is cleared, so the check fails right away with SESSION_REPLACED. The cost is one database read per request. For most apps that's cheap. If it isn't for yours, drop the check and rely on the 60-second expiry.
Before production
The demo keeps things readable. For a real app, also:
- Store a hash of the refresh token, not the token itself, like you would a password. A leaked database then doesn't hand out working tokens.
- Clean up expired rows with a TTL index on
expiresAt(or keep refresh tokens in Redis). - Set a real
JWT_SECRET. The demo falls back to a development value.
Try the theft detection
Clone the repo, start it, and run this against the API. The API takes the refresh token in the JSON body (the Next.js proxy from part 2 normally does this for you):
B=http://localhost:3001/api/v1
json() { sed -E 's/.*"refresh_token":"([^"]+)".*/\1/'; }
# Log in and keep the first refresh token
RT1=$(curl -s -X POST $B/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"demo@example.com","password":"password123"}' | json)
# Use it once: we get RT2, and RT1 is now marked as used
RT2=$(curl -s -X POST $B/auth/refresh \
-H 'Content-Type: application/json' \
-d "{\"refresh_token\":\"$RT1\"}" | json)
# Wait past the grace period, then replay the OLD token
sleep 11
curl -s -X POST $B/auth/refresh \
-H 'Content-Type: application/json' \
-d "{\"refresh_token\":\"$RT1\"}"
# → 401 REUSE_DETECTED
# The whole family is gone, so even the newer token is dead
curl -s -X POST $B/auth/refresh \
-H 'Content-Type: application/json' \
-d "{\"refresh_token\":\"$RT2\"}"
# → 401 Invalid refresh tokenRecap
| Problem | Fix on the backend |
|---|---|
| Refresh token must be cancellable | Random string stored in the database |
| A stolen token works for days | Each token works only once |
| Theft goes unnoticed | Reused token ends the whole family |
| Two requests rotate at once | Atomic claim with where: { used: false } |
| Two tabs look like theft | 10-second grace period |
| Access tokens outlive logout | sid check in the guard |
That's the full series: part 1 for the problem, part 2 for Next.js, and this post for the backend. Together they give sessions that stay logged in smoothly and fail safely.