fix(auth): scope active org per-request to prevent multi-tab leak#3250
Merged
Conversation
Better Auth stores activeOrganizationId on a single session row shared across browser tabs, so switching orgs in one tab leaked into requests from other tabs. Per Better Auth's docs, manage active org client-side and pass orgId per-request instead of persisting to the session. - Server: prefer x-org-id / x-org-slug header (with membership check) over session.activeOrganizationId in authenticateRequest. - Client: replace authClient.organization.setActive with getFullOrganization in shell-layout and the invitation accept flow, so loading a route no longer mutates shared session state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
🧪 BenchmarkShould we run the Virtual MCP strategy benchmark for this PR? React with 👍 to run the benchmark.
Benchmark will run on the next push after you react. |
Contributor
Release OptionsSuggested: Patch ( React with an emoji to override the release type:
Current version:
|
Better Auth's org plugin endpoints (listMembers, inviteMember, removeMember, updateMemberRole, listRoles, createRole, updateRole, deleteRole, cancelInvitation, update, ...) fall back to session.activeOrganizationId when no organizationId is provided. With setActive removed in the prior commit, fresh sessions had no active org and these calls would fail with NO_ACTIVE_ORGANIZATION; pre-existing sessions would silently use a stale value, re-introducing the cross-tab leak through a different code path. - Add useOrgAuthClient hook that wraps authClient.organization and injects organizationId from useProjectContext per call. - Update every org-scoped call site to use the wrapper. - Add ban-direct-auth-client-organization oxlint plugin so the ergonomic trap (calling authClient.organization.X without orgId) becomes a build error. setActive is banned outright as it persists active org to the shared session row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What is this contribution about?
Better Auth's organization plugin stores
activeOrganizationIdon a single session row shared across all browser tabs, so switching orgs in one tab leaked into requests from other tabs (the second tab would silently start hitting the first tab's org). Per Better Auth's docs, the recommended pattern is to manage active org client-side and passorganizationIdper-request rather than persisting it to the session.Two-part fix:
context-factory.ts): inauthenticateRequest, prefer thex-org-id/x-org-slugheader (with a membership check) oversession.activeOrganizationIdfor browser sessions. Falls back to existing session-based behavior when no header is provided. Fails closed (no org context, downstream 403s) if a header is sent for an org the user doesn't belong to.shell-layout.tsx,inbox.tsx): replaceauthClient.organization.setActivewithgetFullOrganizationso loading a route or accepting an invitation no longer mutates the shared session row. The frontend MCP client already sendsx-org-idper-request from the URL/$orgroute, so each tab is naturally isolated.How to Test
/orgAin one tab and/orgBin another.setActivewould overwrite the session and the first tab would start returning data from orgB.Migration Notes
None.
session.activeOrganizationIdis still read as a fallback for clients that don't send the header (webhooks, server-to-server), so existing flows keep working.Review Checklist
bun run check, 19/19 related tests pass)🤖 Generated with Claude Code