Skip to content

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

Description

@wouteth

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

  1. Protect a route with authkitMiddleware({ middlewareAuth: { enabled: true } }).
  2. 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.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions