Describe the bug
There is a refresh-timing asymmetry between the client token store and the middleware:
- The client (
src/components/tokenStore.ts) refreshes before expiry: TOKEN_EXPIRY_BUFFER_SECONDS = 60 (30s for tokens with a <=300s lifetime). parseToken marks a token isExpiring when exp < now + buffer.
- The middleware (
src/session.ts updateSession -> verifyAccessToken) refreshes only reactively. verifyAccessToken is a bare jwtVerify with no clockTolerance and no pre-expiry buffer, so it treats any token where exp > now as valid and passes it straight through:
const hasValidSession = await verifyAccessToken(session.accessToken);
if (hasValidSession) {
newRequestHeaders.set(sessionHeaderName, request.cookies.get(cookieName).value);
// ...token forwarded to withAuth() as-is, no refresh
}
The refresh path (authenticateWithRefreshToken) runs only once jwtVerify fails, i.e. the token is already past exp.
Consequence: a token with a few seconds of life left clears the middleware, is handed to withAuth(), and is passed to a server-side call (a Server Component fetchQuery, an API call to another service, etc.). By the time that downstream service validates the JWT, render latency + the network round-trip + clock skew have pushed it past exp, and it is rejected. The client never hits this because of its 60s head start.
In production this surfaces as a dead-end 500: the downstream auth error is thrown during the RSC render, and Next.js redacts render errors, so an error.tsx boundary cannot classify it as an auth failure and recover.
This is distinct from #289 (client useAccessToken staleness after tab inactivity, fixed in v2.5.0 with focus/visibility listeners) which is the client side only; the middleware still has no buffer.
To Reproduce
- Protect a route with
authkitMiddleware({ middlewareAuth: { enabled: true } }).
- In a Server Component on that route, call a downstream service that validates the WorkOS access token server-side (e.g.
fetchQuery(..., { token: accessToken })), where the service rejects an expired token.
- Let the access token drift to within a few seconds of
exp (easily reached after tab inactivity / sleep throttling, when the client's background refresh timer is throttled). Navigate.
- Intermittently, the middleware forwards the near-expiry token, it expires in transit, the downstream call rejects, and the render 500s.
Expected behavior
The middleware should refresh a near-expiry (but still valid) token proactively, mirroring the client buffer, so a token handed to withAuth() / server-side fetches always has comfortable remaining lifetime.
Proposed fix, either:
- Mirror the client buffer in the middleware: refresh when
exp < now + buffer (same 60s / 30s-for-short-lived thresholds as tokenStore) instead of only after jwtVerify fails.
- Or expose a config option (e.g.
refreshBufferSeconds on authkitMiddleware) so consumers can opt into a pre-expiry refresh window.
Today there is no supported way to add this. As a stopgap we wrap the middleware and re-seal a freshly refreshed session into the request cookie before AuthKit runs, but that replicates internals (the iron-session seal shape, cookie name, getCookieOptions) and is fragile across versions. A first-class buffer or hook would let us drop it.
Screenshots
n/a: the failure is a server-side render 500; there is nothing visual beyond the framework error page.
Desktop (please complete the following information):
- OS: Linux (server-side; Vercel, middleware/
proxy.ts on the Node runtime)
- Browser: n/a (server-side render)
- authkit-nextjs version: 4.2.0
- Next.js version: 16.x
Additional context
Downstream service in our case is Convex (fetchQuery(..., { token })), but the bug is provider-agnostic: it affects any server-side consumer of accessToken that validates exp.
Describe the bug
There is a refresh-timing asymmetry between the client token store and the middleware:
src/components/tokenStore.ts) refreshes before expiry:TOKEN_EXPIRY_BUFFER_SECONDS = 60(30s for tokens with a <=300s lifetime).parseTokenmarks a tokenisExpiringwhenexp < now + buffer.src/session.tsupdateSession->verifyAccessToken) refreshes only reactively.verifyAccessTokenis a barejwtVerifywith noclockToleranceand no pre-expiry buffer, so it treats any token whereexp > nowas valid and passes it straight through:The refresh path (
authenticateWithRefreshToken) runs only oncejwtVerifyfails, i.e. the token is already pastexp.Consequence: a token with a few seconds of life left clears the middleware, is handed to
withAuth(), and is passed to a server-side call (a Server ComponentfetchQuery, an API call to another service, etc.). By the time that downstream service validates the JWT, render latency + the network round-trip + clock skew have pushed it pastexp, and it is rejected. The client never hits this because of its 60s head start.In production this surfaces as a dead-end 500: the downstream auth error is thrown during the RSC render, and Next.js redacts render errors, so an
error.tsxboundary cannot classify it as an auth failure and recover.This is distinct from #289 (client
useAccessTokenstaleness after tab inactivity, fixed in v2.5.0 with focus/visibility listeners) which is the client side only; the middleware still has no buffer.To Reproduce
authkitMiddleware({ middlewareAuth: { enabled: true } }).fetchQuery(..., { token: accessToken })), where the service rejects an expired token.exp(easily reached after tab inactivity / sleep throttling, when the client's background refresh timer is throttled). Navigate.Expected behavior
The middleware should refresh a near-expiry (but still valid) token proactively, mirroring the client buffer, so a token handed to
withAuth()/ server-side fetches always has comfortable remaining lifetime.Proposed fix, either:
exp < now + buffer(same 60s / 30s-for-short-lived thresholds astokenStore) instead of only afterjwtVerifyfails.refreshBufferSecondsonauthkitMiddleware) so consumers can opt into a pre-expiry refresh window.Today there is no supported way to add this. As a stopgap we wrap the middleware and re-seal a freshly refreshed session into the request cookie before AuthKit runs, but that replicates internals (the iron-session seal shape, cookie name,
getCookieOptions) and is fragile across versions. A first-class buffer or hook would let us drop it.Screenshots
n/a: the failure is a server-side render 500; there is nothing visual beyond the framework error page.
Desktop (please complete the following information):
proxy.tson the Node runtime)Additional context
Downstream service in our case is Convex (
fetchQuery(..., { token })), but the bug is provider-agnostic: it affects any server-side consumer ofaccessTokenthat validatesexp.