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.
Temporary passwords (mustChangePassword)
If an admin issued the member a temporary password, the sign-in call says so — route straight to your change-password screen:
completeMfa / commitAccount report it too. The flag is one-shot: it rides the snapshot returned by the sign-in call only, never listAccounts(), and is not persisted — a cached true would survive the password change and trap the user on that screen. Re-check later with getSessionUserData() (GET /auth/me). See Force password change.
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:
Permissions follow the active account. When client changes, AuthProvider clears the previous account's sessionData and session and goes back to status: 'loading' before verifying the new one, and it ignores any late answer from the old client. If the new account can't be verified (the API is down), the provider reports 'unknown' with no permissions; it never carries the previous account's over. It does not retry by itself: call verifySession() again (for example on reconnect) to recover.
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:
From React, useAccountSwitcher().manager is typed IAccountSwitcher, where both methods are optional (CookieAccountManager has a single client and implements neither), so call them as manager.createPendingClient?.() and manager.discardPendingClient?.(client).
- 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.