Skip to content

fix(auth): revoke entire token family on refresh token reuse detection#586

Open
Srejoye wants to merge 3 commits into
Dev-Card:mainfrom
Srejoye:fix/565-refresh-token-reuse-detection
Open

fix(auth): revoke entire token family on refresh token reuse detection#586
Srejoye wants to merge 3 commits into
Dev-Card:mainfrom
Srejoye:fix/565-refresh-token-reuse-detection

Conversation

@Srejoye

@Srejoye Srejoye commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #565.

POST /auth/refresh rotated tokens correctly during normal use but treated "a revoked token was presented" as a simple 401 with no further action. This left the door open for indefinite session forking: an attacker who uses a stolen token first gets a live descendant while the legitimate user silently loses their session with no server-side signal of compromise.

Root cause

// Before — theft signal ignored
if (storedToken.revokedAt) {
  return reply.status(401).send({ error: 'Refresh token revoked' });
}

Presenting an already-revoked token in a rotating-token system means two parties hold copies of the same chain. The correct response is to treat this as a confirmed compromise and nuke the entire family.

Fix

One targeted addition in the revokedAt branch — before the 401, revoke all live tokens in the family and emit a WARN log:

if (storedToken.revokedAt) {
  await app.prisma.refreshToken.updateMany({
    where: { family: storedToken.family, revokedAt: null },
    data: { revokedAt: new Date() },
  });

  app.log.warn(
    { family: storedToken.family, userId: storedToken.userId },
    'Refresh token reuse detected — entire family revoked (possible theft)',
  );

  return reply.status(401).send({ error: 'Refresh token revoked' });
}

The revokedAt: null filter ensures only live descendants are touched — already-revoked tokens are skipped, so the updateMany is safe to call even if the family was partially killed earlier.

Normal rotation is completely unchanged — updateMany is only called in the revoked-token branch.

Files changed

File Change
apps/backend/src/routes/auth.ts Add family-wide revocation + WARN log inside if (storedToken.revokedAt)
apps/backend/src/__tests__/refresh.test.ts New test file: 9 cases covering normal rotation, edge cases, and the full theft-detection scenario

Tests

Normal rotation:

  • Valid token → 200, old token revoked, new token in same family created, updateMany never called
  • Missing token → 401 Refresh token missing
  • Unknown hash → 401 Invalid refresh token
  • Expired token → 401 Refresh token expired, no writes

Reuse-detection:

  • Revoked token → updateMany fires with { family, revokedAt: null }, no new token issued
  • Full A→B→C chain: step-1 rotation succeeds; step-2 stale-A reuse kills live descendants; step-3 legitimate-B attempt gets 401, confirming family is dead
  • updateMany DB failure → 500, no token issued

Checklist

  • Only the revokedAt branch is modified — happy path is untouched
  • revokedAt: null filter makes the updateMany idempotent against already-killed families
  • WARN log includes family and userId for security alerting / SIEM integration
  • No new dependencies
  • Existing logout and rotation behaviour unchanged

The POST /refresh handler checked storedToken.revokedAt and returned 401, but never invalidated the rest of the family. This allowed an attacker who stole a refresh token and used it first to fork the session: the attacker obtained a live descendant token while the legitimate client's next request got a 401, with no server-side signal that theft had occurred. The attacker's forked session could persist for up to 90 days.

Fix: in the revokedAt branch, before returning 401, issue:
  prisma.refreshToken.updateMany({
    where: { family: storedToken.family, revokedAt: null },
    data: { revokedAt: new Date() },
  })
This kills all live descendants in the family, forcing full re-authentication for both the attacker and the legitimate user. A WARN log is emitted with the family ID and userId for alerting.

Normal (non-reuse) rotation is unchanged — updateMany is only called in the revoked-token branch.

Tests added (apps/backend/src/__tests__/refresh.test.ts):
- Normal rotation: revokes old token, creates new in same family, 200.
- Missing / unrecognised / expired token → 401, no mutation.
- Revoked token → updateMany fires with correct family + revokedAt:null filter; no new token issued.
- Full A→B→C chain: step-1 rotation succeeds; step-2 stale-A reuse kills B; step-3 legitimate-B attempt gets 401 confirming family kill.
- updateMany DB failure → 500, no token issued.

Affected: apps/backend/src/routes/auth.ts (POST /refresh)
Tests:    apps/backend/src/__tests__/refresh.test.ts
@vercel

vercel Bot commented Jun 16, 2026

Copy link
Copy Markdown

@Srejoye is attempting to deploy a commit to the Prashantkumar Khatri's projects Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added backend gssoc:approved Required label for every approved PR. Gives the base +50 points and enables contribution tracking. labels Jun 16, 2026
@github-actions

Copy link
Copy Markdown

Hi @Srejoye,

Thanks for opening this pull request.

This PR has been automatically classified based on the files modified.

Applied Labels

  • gssoc:approved
  • backend

Primary Review Area

  • backend

Reviewer

@Harxhit has been identified as the primary reviewer for this pull request.

If you have any questions regarding the affected area or implementation details, feel free to reach out to the assigned reviewer.

Thank you for your contribution!

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown

CI — All Checks Passed

Backend — PASS

Check Result
Lint PASS
Test PASS
Typecheck PASS

Mobile — SKIP

Check Result
Lint -
Test -

Web — SKIP

Check Result
Build -

Last updated: Tue, 16 Jun 2026 18:43:25 GMT

@Harxhit Harxhit left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this out and tested locally. This is a textbook implementation of refresh-token reuse detection — revoking the entire family on replay is exactly the recommended response (OAuth 2.0 Security BCP / RFC 6819). Clean, surgical change.

Verified ✅

  • npm run typecheck → passes
  • npx vitest run src/__tests__/refresh.test.ts8/8 pass
  • eslint on both changed files → clean
  • Change is confined to the if (storedToken.revokedAt) branch — normal rotation, missing/invalid/expired paths are untouched.
  • where: { family, revokedAt: null } makes the updateMany idempotent against an already-partially-revoked family. Correct.
  • findUnique already loads family and userId, so the WARN log fields are populated. Good for SIEM/alerting.

Notes / suggestions

1. (Consideration) Access tokens outlive the family revocation. This correctly kills all live refresh tokens, but any short-lived access JWT the attacker already obtained stays valid until it expires (there's a Redis blocklist used elsewhere for logout). Revoking the refresh family is the right fix for #565 and I wouldn't block on this — but for full session invalidation on confirmed theft, blocklisting the family's active access tokens would close the residual window. Reasonable as a follow-up.

2. (Nit) Test count. The description says "9 cases"; the suite runs 8 it() blocks (all passing). Just a doc/reality mismatch.

3. (Minor, no change needed) Check ordering. revokedAt is evaluated before expiresAt, so a token that is both revoked and expired triggers family revocation rather than returning "expired". That's the desirable precedence for a theft signal — flagging only so it's a conscious choice.

Overall: correct, well-tested, and appropriately scoped. No blockers from me — item #1 is the only thing worth considering, and it's optional/follow-up.

Reviewed locally on the head of fix/565-refresh-token-reuse-detection.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend gssoc:approved Required label for every approved PR. Gives the base +50 points and enables contribution tracking.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Refresh token rotation lacks reuse-detection — stolen token can fork session family indefinitely

2 participants