Skip to content

fix: proactively refresh near-expiry access tokens in the proxy middleware#453

Open
wouteth wants to merge 1 commit into
workos:mainfrom
wouteth:fix/middleware-pre-expiry-refresh-buffer
Open

fix: proactively refresh near-expiry access tokens in the proxy middleware#453
wouteth wants to merge 1 commit into
workos:mainfrom
wouteth:fix/middleware-pre-expiry-refresh-buffer

Conversation

@wouteth

@wouteth wouteth commented Jul 16, 2026

Copy link
Copy Markdown

Fixes #452.

Problem

There is a refresh-timing asymmetry between the client token store and the proxy/middleware:

  • The client (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.
  • The middleware (updateSession -> verifyAccessToken) refreshes only reactively, once the token is already past exp.

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 validates exp), 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 an error.tsx boundary cannot classify it as an auth failure and recover). Full details and a repro in #452.

Fix

updateSession now 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-session header, Set-Cookie.

  • The default buffer mirrors the client token store exactly: 60 seconds, or 30 seconds for tokens with a total lifetime of <= 5 minutes, so the SDK is self-consistent out of the box.
  • A new refreshBufferSeconds option on authkitProxy / authkit() tunes the buffer; 0 disables 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 to 0 is 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). onSessionRefreshError is intentionally not fired in that fallback, since no user-visible auth failure occurred.

Cost note: this adds one authenticateWithRefreshToken call per token lifetime (in the buffer window), not per request.

Testing

  • 8 new tests: buffer boundary in/out, the 30-second short-lived-token tier, custom refreshBufferSeconds, 0 opt-out, the failed-refresh fallback (token served, cookie NOT deleted, no onSessionRefreshError), onSessionRefreshSuccess on proactive refresh, and option threading through updateSessionMiddleware / authkitProxy.
  • pnpm run typecheck, pnpm run lint, pnpm run format:check, pnpm run build, pnpm test all green (393 tests).

🤖 Generated with Claude Code

…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>
@wouteth
wouteth requested a review from a team as a code owner July 16, 2026 17:21
@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds proactive near-expiry access-token refresh to the proxy/session middleware. The main changes are:

  • Adds refreshBufferSeconds to authkitProxy and authkit() options.
  • Refreshes still-valid tokens when they are inside the configured expiry buffer.
  • Falls back to the current valid token when proactive refresh loses a refresh-token rotation race.
  • Documents the new option and behavior in the README.
  • Adds tests for default buffers, custom buffers, opt-out behavior, callbacks, and middleware option threading.

Confidence Score: 5/5

Safe to merge with minimal risk.

The change reuses the existing refresh path, keeps reactive refresh behavior intact, and adds a guarded fallback for proactive refresh failures. Tests cover the new buffer boundaries, opt-out behavior, callbacks, and middleware option plumbing. No blocking correctness or security issues were identified.

No files require special attention.

T-Rex T-Rex Logs

What T-Rex did

  • Ran the Vitest test suite for proactive-refresh and captured output showing 2 test files passed and 74 tests passed.
  • Executed type checking for proactive-refresh and captured output showing EXIT_CODE: 0.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
README.md Documents the new refreshBufferSeconds proxy option and failed proactive-refresh behavior.
src/interfaces.ts Adds refreshBufferSeconds to middleware and session option interfaces with matching documentation.
src/middleware.spec.ts Extends middleware construction coverage to include the new refresh-buffer option.
src/middleware.ts Threads refreshBufferSeconds from authkitProxy into session middleware.
src/session.spec.ts Adds tests for proactive refresh thresholds, opt-out, callback behavior, and failed-refresh fallback.
src/session.ts Adds proactive near-expiry refresh logic while preserving valid-token fallback when refresh races fail.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Req as NextRequest
participant MW as authkitProxy/updateSessionMiddleware
participant Session as updateSession
participant WorkOS as WorkOS refresh API
participant Resp as AuthkitResponse

Req->>MW: Request with `wos-session` cookie
MW->>Session: updateSession(..., `refreshBufferSeconds`)
Session->>Session: Verify access token and check expiry buffer
alt Token valid and outside buffer
    Session-->>Resp: Return current session/token
else Token expired or inside buffer
    Session->>WorkOS: authenticateWithRefreshToken(refreshToken)
    alt Refresh succeeds
        WorkOS-->>Session: New access + refresh tokens
        Session-->>Resp: Set rotated session cookie/header
    else Proactive refresh fails while token still valid
        Session-->>Resp: Serve current token without deleting cookie
    else Reactive refresh fails after expiry
        Session-->>Resp: Delete session cookie and redirect to AuthKit
    end
end
Resp-->>MW: Headers/session
MW-->>Req: Continue or redirect
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Req as NextRequest
participant MW as authkitProxy/updateSessionMiddleware
participant Session as updateSession
participant WorkOS as WorkOS refresh API
participant Resp as AuthkitResponse

Req->>MW: Request with `wos-session` cookie
MW->>Session: updateSession(..., `refreshBufferSeconds`)
Session->>Session: Verify access token and check expiry buffer
alt Token valid and outside buffer
    Session-->>Resp: Return current session/token
else Token expired or inside buffer
    Session->>WorkOS: authenticateWithRefreshToken(refreshToken)
    alt Refresh succeeds
        WorkOS-->>Session: New access + refresh tokens
        Session-->>Resp: Set rotated session cookie/header
    else Proactive refresh fails while token still valid
        Session-->>Resp: Serve current token without deleting cookie
    else Reactive refresh fails after expiry
        Session-->>Resp: Delete session cookie and redirect to AuthKit
    end
end
Resp-->>MW: Headers/session
MW-->>Req: Continue or redirect
Loading

Reviews (1): Last reviewed commit: "fix: proactively refresh near-expiry acc..." | Re-trigger Greptile

@gjtorikian gjtorikian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread src/session.ts
headers: newRequestHeaders,
};
} catch (e) {
if (isExpiring) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
}

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Middleware has no pre-expiry refresh buffer, so a near-expiry token reaches server-side fetches expired (intermittent 500s after idle)

2 participants