Multi-account login & switching
Let one client log into several accounts at once and switch the active one — Gmail/Slack-style — in header or cookie mode.
nest-auth is multi-session by design: every login mints an independent session, and a JWT carries its own sessionId. Logging into a second account never disturbs the first. So "multiple accounts on one device, switch the active one" is mostly a client concern — hold several token sets and choose which to send.
This is especially natural in ISOLATED tenant mode, where the same email in two tenants is two distinct accounts: you sign in to each tenant separately and switch between the resulting sessions. (switch-tenant is a different thing — it re-scopes one session and is disabled in ISOLATED mode.)
1. Enable it on the backend
This is an opt-in capability flag — it doesn't change how sessions are created (they're already concurrent). It's surfaced on GET /auth/client-config as { multipleAccounts: { enabled: true } } so your UI only shows an account switcher when the backend supports it.
Header/bearer (or native secure storage) is the recommended mode. Cookie mode works too but needs the per-account cookie scheme described below.
2. Header mode — AccountManager
AccountManager holds one AuthClient per account, each with its own namespaced storage (so tokens never collide), plus an activeAccountId. Switching is a pure client-side repoint — no server call.
Point your shared HTTP client at the active account:
The manager also implements the same
attachToAxios/attachToFetchhelpers as the single-account client, so a wired instance follows the active account with no re-attach on switch. See HTTP adapters.
React
AccountSwitcherProvider wraps a manager and is separate from AuthProvider (your single-account setup is untouched). It reacts via useSyncExternalStore, so a switch re-renders instantly with no server call.
See Multi-account hooks for the full context value (completeMfa, setAccountMeta, activeAccountId, manager) and the AccountSnapshot shape.
Naming accounts (meta / tenantName / setAccountMeta)
The server session has no tenant display name, so a switcher of several accounts under one shared-owner email all looks the same. Stamp an app-supplied name when you add the account, or later:
tenantName and label land on each AccountSnapshot; render whichever you prefer in the switcher.
MFA on add (completeMfa)
If a login needs a second factor, addAccount throws AccountMfaRequiredError (carrying the pending client). Catch it, show your OTP UI, then call completeMfa(error, verifyDto) — it verifies the challenge on that client and registers the account:
One-shot:
verify2faconsumes the code. If verification succeeds but the commit step throws, do not callcompleteMfaagain (the code is spent) — recover withmanager.commitAccount(pending.client, meta)(themanagerescape hatch is on the context value). Equivalently in the vanilla client: completeverify2faonerror.client, thenmanager.commitAccount(error.client).
The add-account route — <AddAccountGuard>
A plain <GuestGuard> redirects every authenticated user away from the login form, which makes "Add another account" impossible. Wrap the add-account route in <AddAccountGuard> (or set allowWhenAddingAccount on <GuestGuard>) so the form renders even while signed in:
Wire the switcher's "Add another account" button to navigate here (or to /login?add=1, gating with <AddAccountGuard adding={...}>).
3. Cookie mode — CookieAccountManager
In cookie mode the JS never sees the tokens (httpOnly). The server holds one set of per-account cookies (accessToken_<userId> / refreshToken_<userId>) plus a non-httpOnly selector cookie (nest_auth_active_account) naming the active account. The guard reads the selector to pick the active token.
The same <AccountSwitcherProvider config={{ baseUrl, accessTokenType: 'cookie' }}> automatically uses CookieAccountManager — your switcher component is identical.
How it works under the hood:
- List —
GET /auth/accountsreturns the accounts this browser holds cookies for (id/email/tenant + which is active). httpOnly tokens are never returned. - Switch — set
nest_auth_active_account=<id>(the SDK does this viadocument.cookie); the guard then resolves that account's cookie on the next request. - Add — a normal login; the server writes the new account's per-account cookies and points the selector at it.
- Logout — clears just the active account's cookies and promotes another, so signing out of one account doesn't sign the others out.
Security notes
- The selector is not a credential — it only names which of the user's own already-authenticated cookies to use. Forging it can at most make a request act as one of your own logged-in accounts (no cross-user escalation). The real auth is still the httpOnly token cookie.
- Keep
cookieOptions.sameSiteatlax/strictandsecure: truein production, as for single-account cookie auth. - Each account keeps its own rotating refresh token; refresh and reuse-detection are per-session, so holding several accounts never trips the reuse detector.
Don't let your provider and your API client diverge
A manager with no active account resolves no auth headers, so requests through an attached axios go out anonymous and 401 — while an AuthProvider fed a separate bootstrap client still reports the user as signed in. Two token sources, silently disagreeing.
Give the manager a fallbackClient and feed resolveActiveClient() (not getActiveClient()) to the provider, so both sides resolve the same client:
Pass onNoActiveAccount: ({ method }) => … if you'd rather be told (log / redirect to login) than silently send an anonymous request.
reset()andremoveAccount()never touch thefallbackClient— it isn't a managed account, so auth keeps resolving through it afterwards. Log it out yourself if "reset" should mean signed out everywhere.
Cleaning up — reset, discardPendingClient, automatic GC
In header mode each account gets its own namespaced token storage (<prefix>a_<uuid>_*). Three things keep it tidy:
manager.reset()(alsoreset()on the ReactuseAccountSwitcher()) — remove every account, revoke each session server-side (best-effort), and wipe all per-account storage. Use it to enforce a "a plain sign-in starts a fresh single-account session, Add account accumulates" rule:
manager.discardPendingClient(pendingClient)— if youcreatePendingClient()(or catch anAccountMfaRequiredError) and the user abandons the flow (cancels MFA/OTP, navigates away), discard the pending client so the tokens its login wrote don't linger:
- Automatic orphan GC — on
ready()the manager sweeps storage and drops any per-account namespace the index no longer references (e.g. an interrupted add-account or a lost index), so the leak self-heals on the next boot. It needs a persistent storage adapter that exposeskeys()(the built-in local/session adapters do). SetreapOrphanStorageOnReady: falseonly if several managers share one storage and you drive un-indexed pending clients across reloads — then reap manually withdiscardPendingClient/reset.
Notes & limits
maxSessionsPerUser(default 10) is per user — many tenants for the same user could FIFO-evict that user's oldest sessions; raise it if needed.- Always surface the active account's identity in your UI (
activeAccount) so a wrong-token bug can't silently act as the wrong account. - Server-side, there is intentionally no "switch account" endpoint that mutates a session — each account stays on its own session; switching only chooses which one is active.