JWT Refresh Token Rotation — Design Spec
Date: 2026-04-28Status: Approved, pending implementation
Problem
Access tokens are currently 24h/7d depending on login path. The production live-feed SSE endpoint logsJWTExpired as level: error on every expiry, flooding logs. There is no refresh-token rotation — a stolen refresh token remains valid for 90 days with no detection or revocation path.
Goals
- 15-minute access tokens with seamless background refresh (no visible session interruption)
- Refresh token rotation: every refresh issues a new token, invalidates the old
- Reuse detection: a replayed revoked token triggers family-wide session revocation
- Eliminate expired-token error log spam (demote to
info) - All existing auth methods (email, passkey, TOTP, OTP) continue working without logic changes
Non-Goals
- No new D1 tables — KV only for token revocation state
- No changes to MFA, OTP, TOTP, or passkey login flow logic
- No changes to bridge tokens or upgrade invite tokens
- No changes to OTT cross-subdomain auth flow
Section 1: Token Lifecycle & KV Schema
Token lifetimes
| Token | Duration | Change |
|---|---|---|
| Access token | 15 minutes | Was 24h / 7d |
| Refresh token | 90 days | Unchanged |
| Proactive refresh threshold | ≤3 min remaining (80% elapsed) | Was 24h |
KV key: rt:{sha256hex(rawRefreshTokenJWT)}
One record per issued refresh token. TTL = 90 days from issuance. Revoked entries preserve their original TTL (not extended).
KV key: rtf:{familyId}
One record per token family (one login session = one family). Written once on first login; updated to revoked only on reuse detection.
KV key: rtlock:{sha256hex(rawRefreshTokenJWT)}
Value: "1", TTL: 10 seconds.
Best-effort mutex to reduce concurrent refresh races. KV has no compare-and-swap, so this is probabilistic only — it significantly reduces the race window but does not eliminate it. The correct long-term fix would be a D1 transaction; this is the accepted KV mitigation.
Section 2: issueRefreshToken Helper
- Generate refresh token JWT via
generateRefreshToken()(internals unchanged) hash = sha256hex(rawToken)resolvedFamilyId = familyId ?? 'rtf_' + crypto.randomUUID()- KV write
rt:{hash}→ valid metadata JSON, TTL 90d - If
familyIdwasundefined(first login only): KV writertf:{resolvedFamilyId}→{ status: "active", userId }, TTL 90d - Return
{ rawToken, familyId: resolvedFamilyId }
familyId from the old KV entry — the helper skips the rtf: write because the family record already exists.
Section 3: /auth/refresh Endpoint — Rotation Flow
replacedBy in step 11 references newHash, which is computed in step 10 before any KV writes — no forward-reference ambiguity.
Section 4: dualAuthMiddleware Changes
Add a named helper for expired-error detection:
dualAuthMiddleware:
- Expired =
info(normal auth state, not an error) - Invalid =
warn(bad token, worth noting) - Bridge token and upgrade invite token catch blocks are unaffected
Section 5: Login Endpoint Changes
Every path that currently callsgenerateRefreshToken() directly must switch to issueRefreshToken(env, userId, sessionId).
Paths to update:
- Email + password signin
- Passkey verification
- TOTP post-verification
- OTP post-verification
sessionId passed must be the crypto.randomUUID() value that will be stored in users.lastSessionId for that login. No MFA, passkey, TOTP, or OTP logic changes beyond replacing the token issuance call.
Section 6: Frontend — serverComm.ts Changes
Three targeted changes only. Existing refresh mutex (refreshPromise) and _retry guard are preserved.
1. Proactive refresh threshold
Change the threshold passed torefreshTokenIfNeeded() from 24h to 3 minutes:
2. fetchWithAuth() 401 branching
3. SSE live-feed (/notifications/live-feed)
The SSE connection passes the access token as a query parameter. With 15-min tokens, expired connections are expected.
Before opening connection:
What Does Not Change
| Area | Status |
|---|---|
| MFA / passkey / TOTP / OTP login flow logic | Unchanged |
| Bridge token handling | Unchanged |
| Upgrade invite token handling | Unchanged |
generateRefreshToken() / verifyJWT() internals | Unchanged |
| Cookie storage for access tokens | Unchanged |
| localStorage storage for refresh tokens | Unchanged |
| OTT cross-subdomain auth flow | Unchanged |
| Refresh token JWT structure / signing algorithm | Unchanged |

