# 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

```typescript
@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:

```typescript
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.

```typescript
if (!stored) {
  throw new UnauthorizedException('Invalid refresh token');
}

```

```bash
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.

```typescript
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 —

```json
{"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.

```typescript
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

```typescript
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.

```bash
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:

```bash
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:

```typescript
async revokeFamilyByToken(rawToken: string): Promise<void> {
  const stored = await this.refreshTokenRepository.findOne({
    where: { tokenHash: this.hash(rawToken) },
  });
  if (stored) {
    await this.revokeFamily(stored.familyId);
  }
}

```

```bash
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:

```bash
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:

```bash
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 `sha256` 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

```bash
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:

```bash
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](https://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/refresh-token-rotation-demo) repository next to this post.

---

*Published via [ZyVOP](https://zyvop.com/four-ways-a-refresh-token-request-fails-only-one-means-trouble-ozezt?utm_source=hashnode&utm_medium=crosspost&utm_campaign=syndication) — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium & Hashnode in 1 click.*
