Build a JWT Auth Service with Refresh Rotation
A complete JWT auth service: short-lived access tokens, rotating refresh tokens with reuse detection, key rotation, and revocation tests.
Project goal
Issue short-lived access tokens and rotating refresh tokens, detect refresh reuse as theft, and revoke token families on detection.
Final features
- Short-lived access token
- Refresh family
- Rotation
- Reuse detection
- Key rotation
Architecture
Login issues an access JWT signed by the current key and a refresh token stored hashed in a family. Each refresh rotates the token and marks the previous one used; presenting a used token revokes the whole family, forcing re-login.
Prerequisites
- Node.js 20 LTS or later
- PostgreSQL 15+
- An RS256 key pair
Dependencies
- jose ^5.9.6 — JWT signing and verification
- pg ^8.12.0 — Refresh token family storage
- express ^4.21.0 — Auth endpoints
Directory structure
jwt-refresh-service/
├── src/login.ts
├── src/refresh.ts
├── src/keys.ts
├── src/revoke.ts
├── tests/refresh.test.ts
└── package.json
Phases
1. Short-lived access token
Sign an access JWT with a short expiry and the current key id.
new SignJWT({ sub }).setExpirationTime('10m').setProtectedHeader({ alg: 'RS256', kid }).sign(key);
Expected behavior: Short expiry limits the window a stolen access token is useful.
2. Refresh family
Store refresh tokens hashed under a family id.
await db.insert({ familyId, hash: sha256(refresh), used: false });
Expected behavior: A family lets revocation cascade to all descendants.
3. Rotation
Mark the presented token used and issue the next one.
await db.markUsed(hash); const next = randomHex(32); await db.insert({ familyId, hash: sha256(next) });
Expected behavior: Each refresh yields a fresh secret.
4. Reuse detection
Revoke the family if a used token is presented again.
if (row.used) { await db.revokeFamily(familyId); return res.status(401).end(); }
Expected behavior: Reuse is the signal that a token was stolen.
5. Key rotation
Publish a new kid while still verifying the previous.
jwks = { keys: [current.public, previous.public] };
Expected behavior: Rotation without downtime requires an overlap window.
Testing
Login, refresh once, then replay the first refresh token and assert the family is revoked and subsequent refreshes fail.
Troubleshooting
Users logged out unexpectedly
Reuse detection triggered on a client that retried a refresh after a network error; add a small grace by returning the same rotated token for a brief window.
JWKS clients cache the old key
The client cached the JWKS too long; publish the new key before signing with it.
Deployment
Keep access tokens stateless and refresh state in the database. Support key rotation by verifying against a JWKS with the old key present for at least the access token lifetime.
Known limitations
- No device binding or per-device revocation.
- Single active family per user session.
- The example does not implement password reset.
Project overview
Long-lived tokens are convenient and dangerous, and the usual compromise is a short access token paired with a refresh token that lives far longer. This build makes the refresh token rotate on every use and treats a reused one as evidence of theft.
You will issue a short-lived signed access token and a refresh token stored hashed in a family, rotate the refresh token on each use, mark the previous one as used, and revoke the entire family the moment a used token reappears.
Reuse detection is the feature that separates this from ordinary refresh handling. If an attacker steals a refresh token and the legitimate client later presents the same one, the server cannot tell which party is real, so it revokes the family and forces both to re-authenticate.
Access tokens stay stateless so verification needs only the current key, while refresh state lives in the database so it can be revoked. That division is what lets hot paths avoid a database read while keeping revocation possible.
Each phase adds one guarantee: a short access token, a refresh family, rotation, reuse detection, and key rotation, so the flow is safe at every stage.
The service is for teams that have used a single long-lived JWT and want the security of short expiry without forcing users to log in constantly. It keeps the common path stateless and pays the database cost only when a refresh actually happens.
It is also a compact illustration of the difference between authentication and session management: the access token proves identity, while the refresh family records the session and decides when it ends.
What you will build
The service provides five capabilities, each with a test that drives the full flow:
- Short-lived access token
- Refresh family
- Rotation
- Reuse detection
- Key rotation
A short-lived access token limits exposure; a refresh family groups tokens from one login; rotation invalidates each token after use; reuse detection revokes the family on theft; and key rotation lets signing keys change without invalidating live tokens.
The design assumes refresh tokens will leak, and it reacts by making a leak detectable rather than silent, which is the same principle as an audit chain applied to sessions.
Architecture and data model
Login issues an access JWT signed by the current key and a refresh token stored hashed in a family. Each refresh rotates the token and marks the previous one used; presenting a used token revokes the whole family, forcing re-login.
Login issues an access JWT signed by the current key and a refresh token whose hash is stored in a family row. The family records the current token and its predecessors, so a token that has already been rotated past is identifiable.
Each refresh verifies the presented token against the stored hash, issues a new pair, and marks the presented token used. The next time that used token appears, the server treats it as reuse and revokes every token in the family.
Access tokens are verified statelessly against a JWKS, so a normal request needs no database access. Only refresh, which is rare, touches the store, which keeps the hot path fast and the revocation path accurate.
Key rotation is handled by publishing the new key in the JWKS before signing with it and keeping the old key present for at least the access token lifetime, so no live token is rejected during a rotation.
The family id is the unit of revocation, so a reuse detection invalidates every token descended from one login rather than every session the user has. That containment is what limits the blast radius of a false positive.
Prerequisites and dependencies
You need a database for refresh families and a key pair set for signing, plus a stable JWKS endpoint.
- Node.js 20 LTS or later
- PostgreSQL 15+
- An RS256 key pair
The dependencies cover token signing, the HTTP surface, and refresh state storage:
jose(^5.9.6) — JWT signing and verificationpg(^8.12.0) — Refresh token family storageexpress(^4.21.0) — Auth endpoints
pnpm init
pnpm add jose pg express
pnpm add -D typescript tsx vitest
Keep signing keys in a KMS or secret manager and publish only the public half, so a leaked service host cannot mint tokens. Record the access token lifetime and the refresh lifetime in configuration, since both are security decisions rather than tuning knobs.
Directory structure
Separate access token issuance, the refresh family store, rotation, reuse detection, and key publication so each is testable without the others:
jwt-refresh-service/
├── src/login.ts
├── src/refresh.ts
├── src/keys.ts
├── src/revoke.ts
├── tests/refresh.test.ts
└── package.json
Phase 1: Short-lived access token
Sign an access JWT with a short expiry and the current key id.
new SignJWT({ sub }).setExpirationTime('10m').setProtectedHeader({ alg: 'RS256', kid }).sign(key);
Short expiry limits the window a stolen access token is useful.
Issue a short-lived access token with the minimum claims, signed by the current key. A shorter lifetime is the cheapest way to bound the damage of a leaked access token, and it costs only a refresh.
Phase 2: Refresh family
Store refresh tokens hashed under a family id.
await db.insert({ familyId, hash: sha256(refresh), used: false });
A family lets revocation cascade to all descendants.
Store refresh tokens hashed inside a family that groups every token from one login. The family is the unit of revocation, so a compromise is contained to a single session rather than to a user account.
Phase 3: Rotation
Mark the presented token used and issue the next one.
await db.markUsed(hash); const next = randomHex(32); await db.insert({ familyId, hash: sha256(next) });
Each refresh yields a fresh secret.
Rotate on every refresh: issue a new token, mark the old one used, and return the new one. Rotation is what turns a stolen token into a detectable event rather than a permanent second key.
Phase 4: Reuse detection
Revoke the family if a used token is presented again.
if (row.used) { await db.revokeFamily(familyId); return res.status(401).end(); }
Reuse is the signal that a token was stolen.
Detect reuse by rejecting a refresh token that has already been marked used, and revoke the whole family when it happens. Both the attacker and the legitimate client are forced to re-authenticate, which is the safe outcome when the server cannot tell them apart.
Phase 5: Key rotation
Publish a new kid while still verifying the previous.
jwks = { keys: [current.public, previous.public] };
Rotation without downtime requires an overlap window.
Rotate signing keys by publishing before signing and retiring after the access token lifetime, so a rotation is invisible to callers holding live tokens.
Testing
Login, refresh once, then replay the first refresh token and assert the family is revoked and subsequent refreshes fail.
Log in, refresh once, then replay the first refresh token and assert the family is revoked and subsequent refreshes fail. This single test exercises rotation and reuse detection together.
Assert that a valid access token continues to verify after a signing key rotation while the old key remains published, which pins the rotation window behavior.
Assert that a refresh token is stored only as a hash by querying the store for the plaintext and finding nothing, since this is a design intention that a test can enforce.
Assert that an expired or tampered access token is rejected, and that a refresh token from a revoked family cannot mint a new pair.
Use a fixed clock and a deterministic key set so token lifetimes and rotation windows are exercised exactly rather than by sleeping inside a test.
pnpm vitest run
Deployment and operations
Keep access tokens stateless and refresh state in the database. Support key rotation by verifying against a JWKS with the old key present for at least the access token lifetime.
Keep access tokens stateless and refresh state in the database, so verification stays cheap while revocation stays possible. This split is the reason the service can both scale and revoke.
Publish the JWKS at a stable URL and keep the previous key present for at least the access token lifetime, because removing it too early invalidates tokens that are still valid.
Monitor refresh rate and reuse detections, since a spike in either is a security-relevant event that deserves an alert rather than a dashboard glance.
Keep the refresh family table bounded with cleanup of sessions past their refresh lifetime, so the store does not grow without limit as users log in repeatedly.
Treat key rotation as a scheduled, tested operation, because it is the one change that can invalidate live tokens if the windows are set wrong.
Security considerations
Store only hashes of refresh tokens, never expose them in URLs, and set them in HttpOnly cookies where possible. Enforce token binding to a device where the UX allows.
Store refresh tokens hashed and compare in constant time, so a database leak does not hand an attacker a working refresh token.
Bind the refresh token to the client where possible, or require the original client credentials on refresh, so a stolen token alone is not enough to mint a new session.
Revoke the family on reuse rather than only rejecting the single token, because the attacker may hold other tokens from the same login.
Keep signing keys out of the application host where a KMS is available, and never log a token, since a token in a log is a credential in a log.
Rate-limit the refresh endpoint, since it is the one place a stolen token can be turned into a new session and a natural brute-force target.
Observability
Track logins, refreshes, reuse detections, and revocations. Alert on any reuse detection, which is a strong indicator of compromise.
Track refreshes, reuse detections, and revocations separately, because a reuse detection is a security event and a refresh spike may only be a short access token lifetime.
Chart access token verification failures by reason, since a spike in expired tokens after a deploy usually means a clock or a lifetime configuration problem.
Log the family and the key id involved in a revocation without logging the token, so an incident can be reconstructed without creating a new credential exposure.
Measure key rotation lead time, because a rotation that publishes and retires too close together is the most common cause of a token outage.
Track the age of the oldest live access token as a proxy for how stale a compromised token could be, because that window is the real exposure.
Track refresh-token reuse separately from refresh volume, because a single reuse event is worth far more attention than a thousand ordinary refreshes.
Troubleshooting
Users logged out unexpectedly. Reuse detection triggered on a client that retried a refresh after a network error; add a small grace by returning the same rotated token for a brief window.
JWKS clients cache the old key. The client cached the JWKS too long; publish the new key before signing with it.
If every user is suddenly re-authenticated, check the signing key rotation window first, because retiring the old key before the access token lifetime expires rejects live tokens.
If a legitimate client is logged out after a refresh, check for a second client sharing the same refresh token, since reuse detection cannot distinguish that from theft and revokes both.
If a user must re-authenticate unexpectedly, look for a reuse detection in the log before any other cause, since that is the deliberate revocation path.
Limitations and next steps
- No device binding or per-device revocation.
- Single active family per user session.
- The example does not implement password reset.
The service does not implement device binding or proof-of-possession, so a stolen refresh token can still be used once before reuse is detected; sender-constrained tokens are the next step.
Refresh families are stored in a single database, so a multi-region deployment would need replication or a regional partition for the refresh path.
There is no per-user session listing or remote logout UI, though the family model would support it; revocation today is triggered by reuse rather than by an operator action.
The example does not cover delegated authorization or scopes beyond what the access token carries, which a full authorization server would add.
There is no sender-constrained binding, so a token is bearer-only and a stolen refresh token is usable once before reuse detection reacts; proof-of-possession would remove that window entirely.
Access tokens carry no revocation flag, so a stolen access token stays valid for its whole lifetime even after a logout; the short lifetime is the mitigation rather than a revocation list.