Skip to content

fix(auth): scope active org per-request to prevent multi-tab leak#3250

Merged
viktormarinho merged 2 commits into
mainfrom
viktormarinho/multi-tab-org-leak
Apr 30, 2026
Merged

fix(auth): scope active org per-request to prevent multi-tab leak#3250
viktormarinho merged 2 commits into
mainfrom
viktormarinho/multi-tab-org-leak

Conversation

@viktormarinho
Copy link
Copy Markdown
Contributor

What is this contribution about?

Better Auth's organization plugin stores activeOrganizationId on 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 pass organizationId per-request rather than persisting it to the session.

Two-part fix:

  • Server (context-factory.ts): in authenticateRequest, prefer the x-org-id / x-org-slug header (with a membership check) over session.activeOrganizationId for 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.
  • Client (shell-layout.tsx, inbox.tsx): replace authClient.organization.setActive with getFullOrganization so loading a route or accepting an invitation no longer mutates the shared session row. The frontend MCP client already sends x-org-id per-request from the URL /$org route, so each tab is naturally isolated.

How to Test

  1. Sign in to a user that's a member of two orgs (orgA, orgB).
  2. Open /orgA in one tab and /orgB in another.
  3. Trigger MCP tool calls in each tab (e.g., open Connections in both).
  4. Expected: each tab's requests are scoped to its own org — no cross-tab leak. Previously the second tab's setActive would overwrite the session and the first tab would start returning data from orgB.
  5. Accept an organization invitation from the inbox and confirm the redirect lands on the correct org slug without affecting other tabs.

Migration Notes

None. session.activeOrganizationId is still read as a fallback for clients that don't send the header (webhooks, server-to-server), so existing flows keep working.

Review Checklist

  • PR title is clear and descriptive
  • Changes are tested and working (bun run check, 19/19 related tests pass)
  • Documentation is updated (if needed)
  • No breaking changes

🤖 Generated with Claude Code

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>
@github-actions
Copy link
Copy Markdown
Contributor

🧪 Benchmark

Should we run the Virtual MCP strategy benchmark for this PR?

React with 👍 to run the benchmark.

Reaction Action
👍 Run quick benchmark (10 & 128 tools)

Benchmark will run on the next push after you react.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 30, 2026

Release Options

Suggested: Patch (2.293.9) — based on fix: prefix

React with an emoji to override the release type:

Reaction Type Next Version
👍 Prerelease 2.293.9-alpha.1
🎉 Patch 2.293.9
❤️ Minor 2.294.0
🚀 Major 3.0.0

Current version: 2.293.8

Note: If multiple reactions exist, the smallest bump wins. If no reactions, the suggested bump is used (default: patch).

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>
@viktormarinho viktormarinho merged commit bab7570 into main Apr 30, 2026
16 checks passed
@viktormarinho viktormarinho deleted the viktormarinho/multi-tab-org-leak branch April 30, 2026 19:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant