fix: proactively refresh near-expiry access tokens in the proxy middleware#453
Open
wouteth wants to merge 1 commit into
Open
fix: proactively refresh near-expiry access tokens in the proxy middleware#453wouteth wants to merge 1 commit into
wouteth wants to merge 1 commit into
Conversation
…eware The client token store refreshes access tokens 60 seconds before expiry (30 seconds for tokens with a lifetime of 5 minutes or less), but the proxy/middleware refreshed only reactively, once the token was already past exp. A token with a few seconds of life left could pass the middleware, be handed to withAuth() and then to a server-side consumer, and expire in transit (render latency, network round trips, clock skew), surfacing as an intermittent 500 during the RSC render. The middleware now refreshes a still-valid token once it is within the same buffer the client uses, configurable via a new refreshBufferSeconds option (0 disables). If a proactive refresh fails (for example a concurrent request already rotated the single-use refresh token), the request is served with the current, still-valid token instead of deleting the session cookie and redirecting to sign-in. Fixes workos#452 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gjtorikian
requested changes
Jul 17, 2026
gjtorikian
left a comment
Contributor
There was a problem hiding this comment.
Overall looks quite good. In addition to the comment above, consider adding a note in the README's proactive-refresh section that parallel requests inside the buffer window may each pay a failed-refresh round trip before being served.
We can keep this behavior default-on.
| headers: newRequestHeaders, | ||
| }; | ||
| } catch (e) { | ||
| if (isExpiring) { |
Contributor
There was a problem hiding this comment.
I think we should re-check the remaining validity in the catch before falling back:
if (isExpiring) {
const { exp } = decodeJwt(session.accessToken);
if (typeof exp === 'number' && exp > Math.floor(Date.now() / 1000)) {
return respondWithCurrentToken();
}
// fall through to the existing delete-cookie path
}
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #452.
Problem
There is a refresh-timing asymmetry between the client token store and the proxy/middleware:
src/components/tokenStore.ts) refreshes access tokens before expiry: 60 seconds early, or 30 seconds for tokens with a total lifetime of 5 minutes or less.updateSession->verifyAccessToken) refreshes only reactively, once the token is already pastexp.A token with a few seconds of life left passes the middleware unchanged, is handed to
withAuth()and then to a server-side consumer (a Server Component fetch, an API call to a service that validatesexp), and expires in transit due to render latency, network round trips, or clock skew. In production this surfaces as an intermittent 500 during the RSC render (Next.js redacts render errors, so anerror.tsxboundary cannot classify it as an auth failure and recover). Full details and a repro in #452.Fix
updateSessionnow treats a still-valid token that is within a refresh buffer of its expiry as needing refresh, so the existing (already correct) refresh path runs for it: new token pair, re-sealed cookie,x-workos-sessionheader,Set-Cookie.refreshBufferSecondsoption onauthkitProxy/authkit()tunes the buffer;0disables proactive refresh and restores today's reactive-only behaviour. (If you would prefer the feature to be opt-in instead of opt-out, flipping the default to0is a one-line change; happy to do that.)Failed proactive refresh is not fatal. Refresh tokens are single-use, so under parallel navigation requests inside the buffer window, losers of the refresh race get a WorkOS error. The existing catch branch would delete the session cookie and redirect to sign-in, which would turn a benign race into a logout. Instead, when the current token is still valid, the request is served with it (the race winner has already persisted the rotated session).
onSessionRefreshErroris intentionally not fired in that fallback, since no user-visible auth failure occurred.Cost note: this adds one
authenticateWithRefreshTokencall per token lifetime (in the buffer window), not per request.Testing
refreshBufferSeconds,0opt-out, the failed-refresh fallback (token served, cookie NOT deleted, noonSessionRefreshError),onSessionRefreshSuccesson proactive refresh, and option threading throughupdateSessionMiddleware/authkitProxy.pnpm run typecheck,pnpm run lint,pnpm run format:check,pnpm run build,pnpm testall green (393 tests).🤖 Generated with Claude Code