Skip to content

TanStack Start: syncConvexAuthForStartLoader in root beforeLoad replaces ConvexAuthProvider's refreshing fetcher, so the socket goes unauthenticated when the JWT expires #491

Open

Description

@RasmusWinzell

Disclaimer: To be transparent, this is an issue i found in a project of mine that uses kitcn. Claude fixed it and determined it's likely a contradiction in kitcn's guide and example code, and suggested i should file an issue here. The following description is written by AI, hope it helps.

Summary

When you follow the TanStack Start setup guide (skills/kitcn/references/setup/start.md, section 8.B.1), syncConvexAuthForStartLoader runs in the root beforeLoad, which runs on every client-side navigation. Each time the token it receives differs from the previous one, it calls:

authClient.setAuth(async () => token)

This replaces the refreshing fetchAccessToken that ConvexAuthProvider (through ConvexProviderWithAuth) has already bound to the same ConvexReactClient. From then on the client can only return that fixed token. When it expires (15 minutes by default), Convex asks for a new token with forceRefreshToken: true, gets the same expired JWT back, and the socket becomes unauthenticated. Protected queries then fail with unauthorized errors, and route guards send signed-in users to the login page. A full page reload fixes it, because SSR fetches a new token.

This is easiest to hit in long-lived tabs and PWAs: navigate after a token refresh, leave the tab in the background, and come back later.

Versions

  • kitcn 0.25.7, and the same code in 0.33.11 (where the call is setAuth(async () => admit(token) ? token : null))
  • better-auth 1.6.30, convex 1.44.0, @tanstack/react-start 1.168.x

Reproduction

  1. Set up TanStack Start auth exactly as in the guide: ConvexAuthProvider in the root component, and syncConvexAuthForStartLoader({ convex: context.convexQueryClient, getToken: getLoaderToken }) in the root beforeLoad.
  2. Sign in and stay on the page until ConvexAuthProvider has refreshed the JWT once. The refresh also sets the convex_jwt cookie.
  3. Navigate client-side. The root beforeLoad calls getLoaderToken, which returns the new cookie JWT. That token differs from the one stored in startLoaderAuthTokens, so setAuth(async () => token) runs.
  4. Wait until that JWT expires. The Convex client re-authenticates with the fixed fetcher, gets the expired token back, and becomes unauthenticated. Authenticated queries now fail.

Shorter repro: set jwt.expirationSeconds to something small, such as 120.

Why it happens

The guide's own note says to use the helper only for loaders that run "before ConvexAuthProvider mounts". But the example puts it in the root beforeLoad, which also runs on every navigation after the provider has mounted. The helper has no way to tell that a provider already owns the client's auth, so it overwrites it.

Related: server getToken hides errors and drops refreshed cookies

getToken in auth/start/server (from convexBetterAuthReactStart) also causes false sign-outs:

  1. fetchToken returns only data?.token from betterFetch. A 5xx response, a 429 or a network error therefore comes back as undefined, the same as "no session". In beforeLoad that becomes isAuthenticated: false, and the app redirects to login.
  2. The Set-Cookie headers from /api/auth/convex/token are thrown away. That endpoint runs Better Auth's session middleware, which can extend the session (updateAge) and set a new convex_jwt cookie. The session is extended in the database, but the browser cookie keeps its old Max-Age. A user who is active every day can still be signed out on the cookie's original expiry date, because the database refresh that would have re-issued the cookie has already happened.

Possible fixes

  • syncConvexAuthForStartLoader: don't call setAuth when a ConvexAuthProvider is mounted for the client, for example by having the provider register itself in the same registry as the identity guard. Alternatively, return { isAuthenticated, token } without rebinding once the client has been authenticated. The docs could also say explicitly whether running it on every navigation is supported.
  • getToken: treat only a 401 as "no session" and throw on other failures, or return the status so callers can tell the difference.
  • getToken: forward the Set-Cookie headers from the token response to the Start response, for example through getResponseHeaders().append("set-cookie", …).

Workaround we use

On the client, call syncConvexAuthForStartLoader only while the tab hasn't seen an authenticated state yet (first load, or just after sign-in). Once signed in, return { isAuthenticated, token } from our own server function without touching the Convex client. For the route guards we replaced getToken with a direct fetch to /api/auth/convex/token that passes on Set-Cookie and treats only a 401 as signed out.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions