fix: multi-session clerk bug#1374
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds Clerk per-tab session pinning for tRPC auth, server-side fail-closed user resolution, viewer/session matching checks, scoped viewer cache updates, and a Vite React plugin version change. ChangesClerk Multi-Session tRPC Auth
Vite Plugin Dependency Update
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Confidence Score: 5/5Safe to merge. The fix is additive: new optional input on All three defense layers have dedicated unit tests. The No files require special attention. The most complex new file is Important Files Changed
Reviews (7): Last reviewed commit: "fix: route-gating and DRY" | Re-trigger Greptile |
|
/tnr-review-now |
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
|
/tnr-review-now |
Scope covered
Test steps executed
Findings (pass/fail, bugs, risks)
Screenshot index
RecommendationNeeds follow-up. Screenshotstab2 reviewb expected userb got usera Run: View workflow run |
MathiasGruber
left a comment
There was a problem hiding this comment.
Verified the prior round of feedback against the current branch. One item remains unaddressed.
"Completed" label removedThis PR has 1 unresolved review thread(s). The "Completed" label has been automatically removed. Please resolve all review threads before re-applying the label. Unresolved threads:
|
MathiasGruber
left a comment
There was a problem hiding this comment.
Build / test runtime
MathiasGruber
left a comment
There was a problem hiding this comment.
Audit of prior feedback against the rebased branch: all 10 previously-raised review threads are addressed. However, the /tnr-review-now finding from 2026-06-28 still reproduces, and there is one DRY contract worth tightening.
"Completed" label removedThis PR has 2 unresolved review thread(s). The "Completed" label has been automatically removed. Please resolve all review threads before re-applying the label. Unresolved threads:
|


Pull Request
Summary
Fixes a Clerk multi-session bug where a user signed into two accounts in the same browser would intermittently see their profile/character switch to the other account ("I'm on my main, and it sometimes changes my profile to my alt").
Why / Root Cause
Under Clerk multi-session, the active session is per-tab in memory, but the
__sessioncookie the server reads is shared across all browser tabs and reflects whichever tab is currently focused.Our tRPC client authenticated with that ambient cookie only (no token), so a background request from a non-focused tab (the 5-minute
profile.getUserrefetch, or a Pusher-triggeredgetUser.invalidate()) authenticated server-side as whichever account last wrote the cookie (the alt). Becauseprofile.getUserwas queried with no account in its React Query key and a globalstaleTime: Infinity, that wrong-account response was written into the tab's cache and pinned there. The same ambient-cookie path also affected mutations, not just the displayed profile.Verified against the installed
@clerk/backendsource:authenticateRequestis header-first. When anAuthorizationBearer token is present it is used and the cookie/handshake path is skipped.getToken()returns the calling tab's own in-memory session token, independent of the shared cookie.What Was Implemented
1. Per-tab token on every tRPC request (the fix)
app/src/app/_trpc/authHeaders.tswithbuildClerkRequestHeaders(token, isMultiSession): returns{ Authorization: Bearer <token> }when the tab has its own token (and neverBearer null); otherwise a fail-closed marker (see PhpMyAdmin container in docker-compose - $10 reward #3) or{}.app/src/app/_trpc/Provider.tsx:TrpcClientProviderreadsuseAuth().getTokenanduseClerk()(kept in refs so the once-created client always uses the latest values) and attaches headers viahttpBatchLink'sasync headers(). Because Clerk authenticates header-first, the server now resolves this tab's session instead of the shared cookie — for queries and mutations.2. Per-account scoping + server guard (defense-in-depth)
app/src/server/api/viewerGuard.tswithassertViewerMatchesSession(sessionUserId, viewerId?), which throwsFORBIDDENwhen the client-asserted account does not match the server-authenticated session. Identity for the actual work is always taken fromctx.userId;viewerIdis only checked, never trusted.profile.getUsergains an optional{ viewerId }input and calls the guard. The React Query cache is now scoped per account.getUserQueryInput(userId)helper:UserContext.tsx(useQueryplus two optimisticsetData) andPusherHandler.tsx(setData). The ~120 arglessinvalidate()/cancel()calls match by key-prefix and are unchanged.Provider.tsxhandleTrpcErrorsilently ignores the transient "Viewer/session mismatch" guard error (matched by message andFORBIDDENcode), since it self-resolves on the next refetch and is never user-facing.3. Fail-closed when the per-tab token cannot be fetched (defense-in-depth)
getToken()yields no usable token in two cases: it returnsnullwhen the tab has no active session, and it throws (e.g.ClerkOfflineError) on an offline / token-refresh blip. Either way, falling back to the shared cookie could authenticate as another tab's account. Now:Provider.tsxheaders()routes both the null and the thrown path through the same no-token branch ofbuildClerkRequestHeaders. It sends thex-tnr-auth-requiredmarker only when the browser has more than one signed-in session (client.signedInSessions, which excludes ended/expired/replaced sessions); a single-session browser falls back to the cookie (its own account), so ordinary network blips do not cause needless auth failures for the common single-account case.app/src/server/api/authContext.tswithresolveAuthedUserId(sessionUserId, { hasAuthorizationHeader, tabAuthFailed }): returnsnull(treat as unauthenticated) when the marker is present and noAuthorizationheader arrived, instead of authenticating via the shared cookie. Wired into the tRPC context intrpc.ts(the marker is detected by header presence).4. Tests
app/tests/app/_trpc/authHeaders.test.ts:buildClerkRequestHeaders— Bearer format, neverBearer null, fail-closed marker when no token + multi-session, and no marker for a single-session browser.app/tests/server/api/viewerGuard.test.ts: match passes, mismatch throwsFORBIDDEN, no assertion when noviewerId.app/tests/server/api/authContext.test.ts: normal request passes through, Authorization-header-present passes through, marker-without-header fails closed tonull, no-session isnull.Files Changed
app/src/app/_trpc/authHeaders.tsapp/src/app/_trpc/Provider.tsxapp/src/server/api/viewerGuard.tsapp/src/server/api/authContext.tsapp/src/server/api/trpc.tsapp/src/server/api/routers/profile.tsapp/src/utils/UserContext.tsxapp/src/layout/PusherHandler.tsxapp/tests/app/_trpc/authHeaders.test.tsapp/tests/server/api/viewerGuard.test.tsapp/tests/server/api/authContext.test.tsapp/package.json/app/bun.lockBreaking Changes
None.
profile.getUser's new input is optional, so all existing callers (including server-sidecreateCaller/ MCP usages that pass no input) keep working unchanged. The guard is a no-op when noviewerIdis supplied.@vitejs/plugin-react^6→^5) described under Testing Steps.Conformance
The approach was validated against current official docs (Clerk v7, tRPC v11, TanStack Query v5, React 19): the
getToken()+Authorization: Bearerpattern with a fail-closed marker is exactly what Clerk prescribes for multi-tab background fetches; asyncheaders()on the terminatinghttpBatchLink, optional.inputfor cache-keying withctx-derived identity, and per-account query keys are all idiomatic for their respective libraries.Testing Steps
Automated (from repo root)
Manual: confirm the fix is wired (single account, any instance)
/api/trpcrequest and check Request Headers; it now carriesAuthorization: Bearer .... (Previously there was no Authorization header.)Manual: behavioral repro (requires a Clerk instance with multi-session enabled)
/profile.getUser.refetchIntervalfrom300000to3000inapp/src/utils/UserContext.tsx, reproduce, then revert.Screenshots
N/A. No visual/UI changes; the fix is in request authentication and query caching.
Evaluated and intentionally not included
auth()/currentUser()in server components reads the shared cookie. Verified this is not an exposure here:initialIsSignedInis an account-agnostic boolean used only for the pre-hydration layout shell, and the only server components callingcurrentUser()are forum pages that render on foreground navigation (focused tab = correct cookie), useforce-dynamic(so prefetch does not run their data fetch), and surface only public forum content. There is also no clean way to pass a per-tab bearer token to a server component during document render, so no change was made.api/uploadthing/core.tsis still cookie-authenticated and does not traverse the tRPC link. Uploads are foreground/click-driven (the focused tab's cookie is the correct account), so the cross-account precondition is essentially never met. Pre-existing and out of scope for this PR.License
By making this pull request, I confirm that I have the right to waive copyright and related rights to my contribution, and agree that all copyright and related rights in my contributions are waived, and I acknowledge that the Studie-Tech ApS organization has the copyright to use and modify my contribution for perpetuity.
Summary by CodeRabbit