open atlas
↑ Back to track
NestJS, zero to senior NEST · 05 · 03

Refresh tokens, rotation and reuse detection

A JWT access token cannot be revoked before exp, so keep it short (~15 min) and pair it with a long-lived refresh token that rotates single-use. Reusing a consumed refresh token means two parties hold it — revoke the whole family and force re-auth.

NEST Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The incident postmortem had one line that stopped the room: “the attacker had a valid session for 26 days.” Nobody had been phished after the first hour. What happened was simpler and worse — a refresh token leaked through a logging sink that captured request bodies, and the token’s lifetime was 30 days. Every time the attacker’s access token expired, they quietly POSTed the same refresh token to /auth/refresh and got a fresh 15-minute access token back. No alarm fired, because nothing about that request looked wrong: it was a valid token being exchanged exactly as designed. The refresh token was a 30-day skeleton key, and the system had no way to notice it was being used by two people at once. This lesson is about the token lifecycle that closes that hole: why you carry two tokens, why the long-lived one must rotate on every use, and how rotation turns a stolen token into a tripwire instead of a skeleton key.

Why two tokens: the revocation problem

A JWT access token is self-contained: the server verifies its signature and trusts the claims inside without a database lookup. That statelessness is the whole point — it scales, and any service with the public key can validate it. But it has a hard consequence: you cannot revoke a JWT before its exp. There is no server-side record to delete; the token is valid until it expires, period. So if one leaks, your only lever is how short you made exp.

That forces the design. Keep the access token short-lived — ~15 minutes — so a stolen one is a 15-minute problem, not a forever problem. But you can’t make users log in every 15 minutes, so you pair it with a refresh token — long-lived (~7–30 days) — whose only job is to be exchanged at /auth/refresh for a new access token.

import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class TokenService {
  constructor(private readonly jwt: JwtService) {}

  // two tokens, two secrets, two very different TTLs
  async issuePair(userId: string, familyId: string) {
    const accessToken = await this.jwt.signAsync(
      { sub: userId },
      { secret: process.env.ACCESS_SECRET, expiresIn: '15m' },   // short: caps theft blast radius
    );
    const refreshToken = await this.jwt.signAsync(
      { sub: userId, familyId },
      { secret: process.env.REFRESH_SECRET, expiresIn: '30d' },  // long: avoids constant re-login
    );
    return { accessToken, refreshToken };
  }
}

The refresh token is now the sensitive credential — it lives for weeks and mints access tokens on demand. So it must be server-side trackable (unlike the stateless access token), and it must never touch JS-readable storage. It travels as an httpOnly, Secure, SameSite cookie: httpOnly keeps it out of document.cookie so XSS can’t read it, Secure pins it to HTTPS, SameSite blunts CSRF. The access token can sit in memory; the refresh token sits in a hardened cookie.

Rotation: make every refresh single-use

The naive design hands back the same refresh token for its whole 30-day life. That is exactly the skeleton key from the incident: one leak buys 30 days of silent access. Rotation fixes the lifetime problem: on every call to /auth/refresh, you issue a brand-new refresh token and invalidate the old one. Each refresh token is single-use.

async refresh(presentedToken: string) {
  const record = await this.findFamilyRecord(presentedToken); // looks up by hash (below)
  if (!record || record.revoked) throw new UnauthorizedException();

  // ROTATION: the old token is now spent; mint a fresh pair on the same family
  await this.markConsumed(record.id);
  return this.issuePair(record.userId, record.familyId);
}

Rotation does two things. It shrinks the validity window — a leaked refresh token is only good until the legitimate client next refreshes, often minutes, not weeks. And, crucially, it gives you a detection signal: once a token is rotated, presenting it again is anomalous, and anomalies are something you can act on.

Reuse detection: the consumed token is a tripwire

Here is the core mechanism. A rotated refresh token is single-use, so it should never be seen again. If a consumed refresh token is presented a second time, there is only one explanation: two parties hold that token — the legitimate client and a thief. The leak already happened; the duplicate presentation is the system telling you.

The hard part is that you cannot tell which party is which. The request from the attacker and the request from the victim look identical — both carry a once-valid token. So you make the only safe assumption: compromise. You revoke the entire token family — every refresh token descended from the original login — which forces a full re-authentication.

Model it as a family. Each login starts a family (a familyId); every rotation chains the next token onto that same family. The family record tracks which token is currently live and whether the family is revoked.

// On refresh: if the presented token was ALREADY consumed, that's reuse → kill the family
async refresh(presentedToken: string) {
  const record = await this.findByHash(this.hash(presentedToken));
  if (!record) throw new UnauthorizedException();

  if (record.consumed) {
    // REUSE DETECTED: a single-use token came back. Two holders exist.
    await this.revokeFamily(record.familyId);  // nuke every descendant of this login
    throw new UnauthorizedException('token reuse detected');
  }

  await this.markConsumed(record.id);
  const familyId = record.familyId;
  return this.issuePairAndStore(record.userId, familyId);
}

Now replay the incident with rotation in place. The attacker steals a refresh token and uses it to mint an access token — fine, they rotated it, they now hold token v2. Later, the real user’s client refreshes with the token it still holds (the one the attacker already consumed, v1). The server sees a consumed token presented again → reuse detected → it revokes the whole family. Both the attacker’s v2 and any future refresh die. The user is bounced to the login screen, re-authenticates, and starts a fresh family; the attacker is locked out. The 26-day window collapses to “until the user’s next refresh.”

Why this works

Why revoke the WHOLE family on reuse instead of just the token that was replayed? Because once a single-use token reappears, you have proof a leak occurred but no way to tell the attacker’s request from the victim’s — both present a once-valid token, and there is no signal that distinguishes them. If you revoke only the presented token, you might revoke the victim’s copy and leave the attacker holding the freshly rotated one — you’d lock out the legitimate user and keep the thief logged in, the exact inverse of what you want. The only safe assumption is that the whole chain descended from that login is compromised, so you revoke every token in the family and force a clean re-authentication. It’s a deliberate trade: one leaked token costs the user a single re-login, and in exchange the attacker is guaranteed out.

Storage: hash the refresh token, never store it raw

The family record lives in your database — but you must not store the raw refresh token in it. If you did, a database leak (a dump, a backup left in a bucket, a read-only SQLi) would hand an attacker every live refresh token in plaintext: instant account takeover at scale, no signature forgery needed. Instead, store only a hash of the refresh token — argon2/bcrypt, or a plain SHA-256 since the token is already high-entropy — exactly as you’d store a password. On refresh, hash the presented token and compare against the stored value.

import { createHash } from 'node:crypto';

// the token is high-entropy random already, so a fast SHA-256 is fine here
private hash(token: string): string {
  return createHash('sha256').update(token).digest('hex');
}

private async issuePairAndStore(userId: string, familyId: string) {
  const { accessToken, refreshToken } = await this.tokens.issuePair(userId, familyId);
  await this.familyRepo.save({
    familyId,
    userId,
    tokenHash: this.hash(refreshToken), // store the HASH, never the raw token
    consumed: false,
    revoked: false,
  });
  return { accessToken, refreshToken };
}

The wiring in Nest mirrors the access path but stays separate: a JwtRefreshStrategy (distinct from the access JwtStrategy) reads the refresh token from the cookie and validates its signature with the refresh secret; the /auth/refresh endpoint is guarded by it; and the service does the stateful part — look up the family by hash, check consumed/revoked, rotate, store the new hash, return the new pair. @nestjs/jwt signs both tokens, but with different secrets and different TTLs, so an access token can never be replayed as a refresh token or vice versa.

Pick the best fit

A refresh token will inevitably leak someday — through a log sink, an XSS bug, or a stolen device. How do you limit the damage from a stolen refresh token?

Quiz

Why is the access token kept short-lived (~15 min) instead of just issuing one long-lived JWT?

Quiz

A refresh token that was already rotated (consumed) is presented to /auth/refresh a second time. What should the server do?

Recall before you leave
  1. 01
    Why do you carry two tokens, and what are the right lifetime, storage, and properties of each?
  2. 02
    Walk through rotation and reuse detection: what rotation does, what a reused consumed token means, and why you revoke the whole family.
Recap

The token lifecycle exists to solve one hard fact: a self-contained JWT access token cannot be revoked before its exp, because there is no server-side record to delete. So you keep the access token short-lived (~15 min) to cap the blast radius of theft, and pair it with a long-lived refresh token (~7–30 days) that is exchanged at /auth/refresh for new access tokens — the refresh token being the sensitive credential, carried in an httpOnly, Secure, SameSite cookie and stored in the DB only as a hash so a leak yields nothing usable. Rotation makes every refresh single-use: each call issues a new refresh token and invalidates the old one, shrinking a leaked token’s validity to minutes and creating a detection signal. Reuse detection is the core mechanism: a consumed single-use token presented again means two parties hold it, and since you cannot tell attacker from victim, you assume compromise and revoke the entire token family (all descendants of that login, chained by familyId), forcing re-auth. In NestJS this is a separate JwtRefreshStrategy reading the cookie, a guarded /auth/refresh endpoint, and a service that validates the presented token’s hash against the family record, rotates, and returns a new pair — with @nestjs/jwt signing both tokens under different secrets and TTLs. The net effect: rotation plus reuse detection turns a stolen refresh token from a multi-week skeleton key into a self-revoking tripwire.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.