JWT Refresh Token Rotation — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Replace 24h/7d access tokens with 15-minute tokens backed by full refresh-token rotation, family-level reuse detection, and KV revocation — without changing any MFA, passkey, TOTP, or OTP login flow logic.
Architecture: A new refresh-tokens.ts helper centralises all KV reads/writes and refresh token issuance. Every login endpoint swaps generateRefreshToken() for issueRefreshToken(). The /auth/refresh endpoint is replaced with the full 15-step rotation flow. dualAuthMiddleware returns a clean TOKEN_EXPIRED 401 (not an error log) for expired access tokens. Frontend serverComm.ts gets a 3-min proactive threshold and error-code-specific 401 handling.
Tech Stack: Hono, jose, Cloudflare Workers KV (OTT_STORE namespace with rt: / rtf: / rtlock: prefixes), Drizzle ORM, Vitest (server unit tests), React (frontend)
Spec: docs/superpowers/specs/2026-04-28-jwt-refresh-rotation.md
File Map
| File | Action | Purpose |
|---|---|---|
server/src/lib/refresh-tokens.ts | Create | sha256hex, issueRefreshToken, KV CRUD helpers, isJwtExpiredError |
server/src/middleware/auth-enhanced.ts | Modify | Replace generic JWT error catch with isJwtExpiredError + info log + TOKEN_EXPIRED 401 |
server/src/routes/auth.ts | Modify | Replace /auth/refresh handler; swap all generateRefreshToken() calls; change '7d' → '15m' access token in refresh |
server/src/lib/nextauth.ts | Modify | Change generateJWT default expiry from '24h' → '15m' |
ui/src/lib/serverComm.ts | Modify | Threshold 3 min; 401 code branching |
ui/src/hooks/useClinicEvents.ts | Modify | Add refreshTokenIfNeeded before EventSource open and on reconnect |
ui/src/components/providers/NotificationProvider.tsx | Modify | Pass explicit 3-min threshold; add 12-min proactive reconnect timer |
Task 1: Create server/src/lib/refresh-tokens.ts
Files:
- Create:
server/src/lib/refresh-tokens.ts
- Step 1: Write the file
- Step 2: Verify TypeScript compiles
refresh-tokens.ts. If KVNamespace type is missing, add /// <reference types="@cloudflare/workers-types" /> at the top of the file.
- Step 3: Commit
Task 2: Update dualAuthMiddleware — clean 401 for expired tokens
Files:
- Modify:
server/src/middleware/auth-enhanced.ts:83-121
console.error and returns a generic 401. We split this into expired vs invalid.
- Step 1: Import
isJwtExpiredError
server/src/middleware/auth-enhanced.ts, add the import after the existing imports:
- Step 2: Replace the JWT catch block
catch (jwtError) block). The block currently starts at:
- Step 3: Verify TypeScript compiles
- Step 4: Commit
Task 3: Replace /auth/refresh endpoint with full rotation flow
Files:
- Modify:
server/src/routes/auth.ts:2982-3089
- Step 1: Add imports at the top of
auth.ts
server/src/routes/auth.ts. Add after the existing imports from ../lib/nextauth:
- Step 2: Replace the handler body
auth.post('/refresh', ...) block (lines 2982–3089) with:
Note on the pre-generate pattern: Because we needreplacedBy: newHashin the revocation record, we must know the new token hash before writing the revocation. We generate the raw JWT and its hash first (steps 7), then revoke old (step 8), then write new KV record directly (step 9). This avoids the double-write problem that would occur if we calledissueRefreshToken(which generates its own JWT and hash). The NINETY_DAYS_SECONDS constant is duplicated here intentionally — it keeps the refresh handler self-contained without importing an internal constant.
- Step 3: Verify TypeScript compiles
KVNamespace is unresolved, ensure @cloudflare/workers-types is in tsconfig.json types array.
- Step 4: Smoke test the refresh endpoint locally (if Wrangler dev is running)
{ "token": "...", "refreshToken": "..." } with HTTP 200.
- Step 5: Commit
Task 4: Update all login endpoints to use issueRefreshToken
Files:
- Modify:
server/src/routes/auth.ts— 8 call sites
generateRefreshToken({...}, secretFromEnv) with issueRefreshToken({...}, kv). The login flow logic itself does not change at all — only the token issuance call.
For each call site below, the pattern is:
Before:
Important:issueRefreshTokenrequiressessionId. If the existing call site does not have one, generate it withconst sessionId = crypto.randomUUID()and also write it to D1:await db.update(users).set({ lastSessionId: sessionId }).where(eq(users.id, userId)).
- Step 1: Update superadmin login (auth.ts ~line 390)
generateRefreshToken({ sub near line 390. The superadmin session is not tracked in D1 (isSuperAdmin = true), so no lastSessionId update needed. Apply the pattern above with userId = superAdminRecord.id.
- Step 2: Update regular signin (auth.ts ~line 663)
generateRefreshToken({ sub near line 663. This call site already has sessionId. Apply the pattern. The kv binding line can be added just above the call.
- Step 3: Update OTP/2FA verification (auth.ts ~line 881)
generateRefreshToken({ sub near line 881. This path already has sessionId. Apply the pattern. This covers TOTP and OTP paths that converge at this handler.
- Step 4: Update onboarding/registration (auth.ts ~line 1281)
generateRefreshToken({ sub near line 1281. Apply the pattern; sessionId already present.
- Step 5: Update staff invite accept (auth.ts ~line 2589)
generateRefreshToken({ sub near line 2589. This call site uses { sub, email } — no sessionId.
Add before the call:
sessionId.
- Step 6: Update set-password (auth.ts ~line 2745)
generateRefreshToken({ sub near line 2745. Same situation — no sessionId.
Add before the call:
- Step 7: Update impersonation restore — user path (auth.ts ~line 2889)
generateRefreshToken({ sub near line 2889. sessionId is present. Apply the pattern. The impersonation logic itself (the impersonatorId on the access token) is untouched.
- Step 8: Update impersonation restore — superadmin path (auth.ts ~line 2933)
generateRefreshToken({ sub near line 2933. sessionId is present. Apply the pattern.
- Step 9: Verify TypeScript compiles with zero errors
- Step 10: Verify no remaining raw
generateRefreshTokencalls in login paths
nextauth.ts — only the call sites in auth.ts should be gone.)
- Step 11: Commit
Task 5: Shorten access token lifetime to 15 minutes
Files:-
Modify:
server/src/lib/nextauth.ts:24 - Step 1: Change the default expiry
server/src/lib/nextauth.ts line 24, the function signature is:
'24h' to '15m':
- Step 2: Scan for other hardcoded expiry strings in auth.ts
generateJWT call that explicitly passes '24h' or '7d', change it to '15m'. The /auth/refresh handler was already fixed in Task 3 to pass '15m' explicitly.
- Step 3: Verify TypeScript compiles
- Step 4: Commit
Task 6: Frontend — 401 branching + refresh threshold in serverComm.ts
Files:
- Modify:
ui/src/lib/serverComm.ts
serverComm.ts. Do not touch anything else in this file.
- Step 1: Change the proactive refresh threshold
refreshTokenIfNeeded inside fetchWithAuth (around line 763). It is called with no argument or with a large threshold (24h = 86_400_000). Change it to pass 3 minutes:
refreshTokenIfNeeded function default parameter (around line 710):
- Step 2: Update the 401 handler in
fetchWithAuth
Key points: The_retry: trueflag prevents infinite retry loops.SESSION_REVOKEDnever triggers a refresh attempt. Other 401s (e.g.,INVALID_TOKEN,ConcurrentSession) are propagated as-is.
- Step 3: Verify TypeScript compiles
- Step 4: Commit
Task 7: Frontend — SSE reconnect with token refresh
Files:- Modify:
ui/src/hooks/useClinicEvents.ts - Modify:
ui/src/components/providers/NotificationProvider.tsx
Part A — useClinicEvents.ts
The current hook opens an EventSource without refreshing the token first and does not refresh before reconnecting. With 15-min tokens, every reconnect must use a fresh token.
- Step 1: Import
refreshTokenIfNeededandgetAuthToken
ui/src/hooks/useClinicEvents.ts, ensure these are imported:
getAuthToken is already imported from auth-cookie.ts instead, keep that import.)
- Step 2: Add refresh before EventSource construction
EventSource is constructed (it will be something like new EventSource(\…?token=$“). Wrap the construction logic so it always refreshes first:
- Step 3: Add refresh before each reconnect
- Step 4: Add 12-minute proactive reconnect timer
Part B — NotificationProvider.tsx
The connectSSE function (lines ~194–278) already calls refreshTokenIfNeeded() before reconnection but uses the default threshold. Tighten it and add proactive reconnect.
- Step 5: Pass explicit threshold to
refreshTokenIfNeeded
refreshTokenIfNeeded() without arguments in NotificationProvider.tsx and add the explicit threshold:
- Step 6: Add 12-minute proactive reconnect timer
connectSSE, after the EventSource opens successfully, add:
- Step 7: Handle SESSION_REVOKED in SSE error path
storage event listener and auth:force-logout handler in NotificationProvider.tsx, ensure that a SESSION_REVOKED error from the /auth/refresh call also stops SSE reconnect attempts. The existing auth:force-logout event dispatch from serverComm.ts already handles this — verify the listener is wired up:
- Step 8: Verify TypeScript compiles
- Step 9: Commit
Task 8: End-to-end smoke test
No code changes. Verify the full flow works across all auth paths.- Step 1: Deploy to staging
odontox-commit-deploy skill or run the standard staging deploy.
- Step 2: Verify email + password login
- Log in via email + password
- Open DevTools → Application → localStorage — confirm
odontox_rtis present - Open DevTools → Application → Cookies — confirm
auth_tokencookie is present and has ~15-min expiry - Check Cloudflare Worker logs — confirm no
JWTExpiredaterrorlevel
- Step 3: Verify TOTP / OTP login
- Log in with 2FA enabled (TOTP or OTP path)
- Confirm same token storage as above
- Confirm protected API calls succeed
- Step 4: Verify passkey login
- Log in via passkey
- Confirm
auth_tokencookie andodontox_rtin localStorage - Confirm protected API calls succeed
- Step 5: Verify refresh cycle
- Open DevTools Network tab
- Wait 12–13 minutes (or manually expire the cookie by editing its value to an old JWT in Application tab)
- Make any API call — observe a POST to
/api/v1/auth/refreshsucceed with 200 - Observe the original API call retry and succeed
- Confirm
odontox_rtin localStorage has been updated to a new value
- Step 6: Verify SESSION_REVOKED triggers logout
- Log in and capture the refresh token from localStorage
- In a second session (incognito), log in and refresh — this rotates the refresh token
- Back in the first session, trigger a 401 TOKEN_EXPIRED (wait or manually expire cookie)
- The frontend will attempt a refresh using the now-revoked token
- Expect: redirect to login page (SESSION_REVOKED caught,
auth:force-logoutdispatched)
- Step 7: Verify live-feed SSE reconnects cleanly
- Open the notifications panel
- Wait 12 minutes or manually close/reopen the SSE connection
- Confirm SSE reconnects without a
401error in the Network tab - Confirm Worker logs show no
JWTExpirederrors
- Step 8: Verify Cloudflare Worker logs are clean
odonto-prod → Logs:
JWTExpiredentries should no longer appear atlevel: error- Any token expiry should appear at
level: infoif logged at all
Self-Review
Spec coverage check:| Spec requirement | Task |
|---|---|
| 15-min access tokens | Task 5 |
issueRefreshToken helper | Task 1 |
KV rt:, rtf:, rtlock: records | Task 1 |
| Revoke-before-issue ordering | Task 3 |
| Race condition lock | Task 1 + Task 3 |
| Family-wide revocation on reuse | Task 3 |
lastSessionId cleared only if matches | Task 3 |
isJwtExpiredError helper | Task 1 |
dualAuthMiddleware → info log + TOKEN_EXPIRED | Task 2 |
All login endpoints → issueRefreshToken | Task 4 |
rtf: written on first login only | Task 1 |
| Frontend 3-min threshold | Task 6 |
Frontend TOKEN_EXPIRED retry-once | Task 6 |
Frontend SESSION_REVOKED → logout | Task 6 |
| SSE refresh before open + reconnect | Task 7 |
| SSE 12-min proactive reconnect | Task 7 |
| Bridge / upgrade invite tokens untouched | Tasks 2, 3 (explicitly preserved) |
| MFA / TOTP / OTP logic untouched | Task 4 (mechanical swap only) |

