Repository navigation
Description
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
- Set up TanStack Start auth exactly as in the guide:
ConvexAuthProviderin the root component, andsyncConvexAuthForStartLoader({ convex: context.convexQueryClient, getToken: getLoaderToken })in the rootbeforeLoad. - Sign in and stay on the page until
ConvexAuthProviderhas refreshed the JWT once. The refresh also sets theconvex_jwtcookie. - Navigate client-side. The root
beforeLoadcallsgetLoaderToken, which returns the new cookie JWT. That token differs from the one stored instartLoaderAuthTokens, sosetAuth(async () => token)runs. - 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:
fetchTokenreturns onlydata?.tokenfrombetterFetch. A 5xx response, a 429 or a network error therefore comes back asundefined, the same as "no session". InbeforeLoadthat becomesisAuthenticated: false, and the app redirects to login.- The
Set-Cookieheaders from/api/auth/convex/tokenare thrown away. That endpoint runs Better Auth's session middleware, which can extend the session (updateAge) and set a newconvex_jwtcookie. The session is extended in the database, but the browser cookie keeps its oldMax-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 callsetAuthwhen aConvexAuthProvideris 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 theSet-Cookieheaders from the token response to the Start response, for example throughgetResponseHeaders().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