Four Ways a Refresh Token Request Fails - Only One Means Trouble
DEV Community

Four Ways a Refresh Token Request Fails - Only One Means Trouble

Four Ways a Refresh Token Request Fails - Only One Means Trouble

A refresh token exists for one reason: exchange itself for a new access token, once, and then stop being useful. Everything about a good implementation follows from taking "once" literally. This one does - every refresh token is single-use, and grouped with every other token descended from the same login into a family.

Calling POST /auth/refresh with a given token can fail four different ways, and three of them are just bookkeeping. The fourth is the one this post is actually about.

One row per token, one family per login

@Entity()
export class RefreshToken {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  userId: string;

  @Index()
  @Column()
  familyId: string;

  @Index({ unique: true })
  @Column()
  tokenHash: string;

  @Column({ default: false })
  used: boolean;

  @Column({ default: false })
  revoked: boolean;

  @Column({ type: 'timestamp' })
  expiresAt: Date;

  @CreateDateColumn()
  createdAt: Date;
}

familyId is what makes "family" more than a metaphor - it's set once, at login, and then carried forward unchanged into every row that session's rotation produces afterward:

async login(@Body() dto: LoginDto) {
  const user = await this.authService.validatePassword(dto.email, dto.password);
  const accessToken = this.authService.issueAccessToken(user.id, user.email);
  const refresh = await this.refreshTokenService.issue(user.id);
  // no familyId = fresh session
  return { accessToken, refreshToken: refresh.rawToken };
}

Omitting familyId here is the signal that this is a brand new session rather than a rotation - issue() generates a fresh one. Every later call to issue() for this same chain, from inside rotate(), passes that same value back in instead. Two logins from the same user, at the same time, produce two completely independent families - there's nothing linking them beyond sharing a userId, which matters later.

Three ordinary rejections

A token that was never real. Someone sends a string that doesn't hash to anything in the table.

if (!stored) {
  throw new UnauthorizedException('Invalid refresh token');
}
curl -X POST http://localhost:3000/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"not-a-real-token-at-all"}'
# -> 401 {"message":"Invalid refresh token", ...}

A token that aged out. Refresh tokens in this implementation live 7 days by default. Past that, they're just gone.

if (stored.expiresAt < new Date()) {
  throw new UnauthorizedException('Refresh token has expired');
}

Confirmed directly rather than waiting a week: backdate a token's expiresAt in Postgres by a day, then try to use it:

{
  "message": "Refresh token has expired",
  "error": "Unauthorized",
  "statusCode": 401
}

Exactly the branch that's supposed to fire.

A token whose session already ended. Logout, or (as covered below) a reuse event elsewhere in the same family, flips a revoked flag.

if (stored.revoked) {
  throw new UnauthorizedException('Refresh token has been revoked');
}

None of these three are interesting on their own - they're the normal outcomes of a token being wrong, old, or deliberately ended. The fourth rejection reason looks identical from the outside (same 401, same shape) but means something completely different underneath.

The fourth: a token that's already been spent

if (stored.used) {
  await this.revokeFamily(stored.familyId);
  throw new UnauthorizedException('Refresh token reuse detected; session revoked');
}

stored.used = true;
await this.refreshTokenRepository.save(stored);
return this.issue(stored.userId, stored.familyId);

Every successful refresh marks the token it consumed as used before issuing its replacement. A legitimate client only ever moves forward through that chain - it gets a new token and uses that one next time, never the old one again.

So if an already-used token shows up in a request, the client presenting it isn't following the chain. Either it's a genuine client retrying a request whose response got lost on the way back (a real possibility, not a hypothetical), or it's a second party holding a copy of a token the real owner has already moved past. There's no way to tell those apart from inside this one request - both look exactly like "this exact token, again" - so both get treated as the same signal: something's wrong with this session, not just this token.

That's why the response isn't "reject this token and move on." It's revokeFamily, which doesn't touch just the token that got reused - it kills every unrevoked row sharing that familyId, including whichever token the legitimate client is currently holding as its "real" one.

Proving that actually happens, not just reading the code and assuming: rotate once (RT1 โ†’ RT2), then try RT1 again.

curl -X POST http://localhost:3000/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"<RT1>"}'
# -> 401 {"message":"Refresh token reuse detected; session revoked", ...}

RT1 failing is expected - it's already used. The real test is what happens to RT2, which was never itself reused, was legitimately issued, and - if the family weren't revoked - should still work fine:

curl -X POST http://localhost:3000/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"<RT2>"}'
# -> 401 {"message":"Refresh token has been revoked", ...}

RT2 dies too, and the error message even confirms why - not "reuse detected" (that already happened, on RT1) but plain "revoked," which is exactly what a downstream consequence of someone else's reuse should look like from RT2's own perspective.

This is the actual security property, not the headline description of it: a compromise anywhere in the chain takes down the entire chain, including the part that was never touched.

The blast radius runs the other direction too, and it's worth confirming it actually stops where it should: a second user, bob@example.com, with his own completely separate family, refreshes without incident while all of the above is happening to alice's account. Nothing about bob's session is touched - revokeFamily only ever operates on rows sharing the one familyId it was given, and bob's family was never that one. The failure is total within a compromised family and entirely absent outside it.

Logging out is the same mechanism, aimed on purpose

Every revocation above happened as a side effect of something going wrong. Logout is the identical underlying operation - revokeFamily - called directly, when nothing is wrong at all:

async revokeFamilyByToken(rawToken: string): Promise<void> {
  const stored = await this.refreshTokenRepository.findOne({
    where: { tokenHash: this.hash(rawToken) },
  });
  if (stored) {
    await this.revokeFamily(stored.familyId);
  }
}
curl -X POST http://localhost:3000/auth/logout \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"<RT>"}'
# -> { "message": "Logged out" }

curl -X POST http://localhost:3000/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"<RT>"}'
# -> 401 {"message":"Refresh token has been revoked", ...}

One question this raises immediately: does logging out on one device end every session, or just the one that called it? Worth an actual answer instead of a guess - log in twice for the same user (call them a phone session and a laptop session, two independent families), log out using only the phone's token, then try refreshing each:

  • phone session, after logout: { "message": "Refresh token has been revoked", ... }
  • laptop session, untouched: { "accessToken": "...", "refreshToken": "..." } (succeeds normally)

Logging out ends the one family attached to the token that was actually presented. It has no way to reach a different family for the same user, because nothing about revokeFamily looks at userId at all - only familyId. Two logins are two unrelated chains that happen to belong to the same person, and ending one says nothing about the other.

What doesn't die with the family

Everything above is about the refresh token. The access token issued alongside RT2 - call it AT2 - is a separate, stateless JWT with its own 15-minute clock, and revoking a refresh family doesn't reach into that clock:

curl http://localhost:3000/auth/me \
  -H "Authorization: Bearer <AT2>"
# -> 200 { "id": "...", "email": "alice@example.com" }

Issued before the reuse event above, and it still works after the whole family got revoked. That's not a gap in the implementation - it's the actual reason access tokens are kept short-lived in the first place. There's no database row for an access token to flip a revoked bit on; the only thing bounding how long a leaked one stays useful is its own expiry. Fifteen minutes is the real ceiling on "how bad is it if this specific token leaks," independent of anything the refresh layer detects or reacts to.

Why the token hash is SHA-256, not bcrypt

The password column in this same project uses bcrypt, and it should - passwords are short, human-chosen, and drawn from a relatively small effective space, so a hash that's deliberately slow is what makes brute-forcing a stolen hash impractical.

A refresh token is a different kind of secret: 40 bytes of crypto.randomBytes, 320 bits of pure randomness, with no human-guessable structure to exploit. Brute-forcing a hash of that is infeasible no matter how fast the hash function is, so bcrypt's deliberate slowness buys nothing here - it just adds cost to every single refresh call for no security benefit. A plain, fast SHA-256 is the correct tool once the secret's strength comes from entropy rather than from making guessing expensive.

Trying this out, and what it doesn't cover

npm install && cp .env.example .env
docker run --name refresh-postgres \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=refresh_demo \
  -p 5432:5432 -d postgres:16
npm run start:dev

synchronize: true handles table creation for this demo; swap it for real migrations before this touches production.

Register and log in to get real values for the <RT1> / <RT2> / <AT2> placeholders used throughout above:

curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"correct-horse-battery"}'

curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"correct-horse-battery"}'
# -> { "accessToken": "<AT1>", "refreshToken": "<RT1>" }

Left out on purpose:

  • A cleanup job for expired and revoked rows. They'll otherwise just accumulate - the BullMQ patterns from elsewhere in this series are a natural fit for sweeping them out.
  • Rate limiting on /auth/login and /auth/refresh. Both are obvious targets, and the earlier rate-limiting post in this series covers them directly.
  • A grace period for the "legitimate retry" half of the reuse ambiguity. Some production systems let a just-rotated token keep working for a few seconds, specifically to absorb a lost-response retry before treating reuse as a hard signal. This implementation takes the strict stance instead - simpler to reason about, simpler to verify - which is a deliberate tradeoff, not an oversight.

The full project, including the parts of this walkthrough not reproduced above, is in the refresh-token-rotation-demo repository next to this post.

Originally published on ZyVOP ๐Ÿ’ก For more articles like this, subscribe to the ZyVOP newsletter!

Comments

No comments yet. Start the discussion.