Nest Authbeta

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

NestAuthModule.forRoot({
  session: {
    allowMultipleAccounts: true,
    // header/bearer is the simplest mode; cookie mode is also supported (below)
    accessTokenType: 'header',
  },
});

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.

import { AccountManager } from '@ackplus/nest-auth-client';
 
const accounts = new AccountManager({
  baseUrl: 'https://api.example.com',
  accessTokenType: 'header',
});
 
// Add accounts (each is an independent login; the others are untouched)
await accounts.addAccount({ providerName: 'email', credentials: { email: 'me@acme.test', password } });
await accounts.addAccount({ providerName: 'email', credentials: { email: 'me@globex.test', password } });
 
accounts.listAccounts();        // [{ accountId, email, isActive }, …]
await accounts.switchAccount(idA); // repoint active — no network
await accounts.getAuthHeaders();   // Authorization header for the ACTIVE account
await accounts.removeAccount(idB); // revokes that session server-side + drops it

Point your shared HTTP client at the active account:

import axios from 'axios';
axios.interceptors.request.use(async (config) => {
  Object.assign(config.headers, await accounts.getAuthHeaders());
  return config;
});

The manager also implements the same attachToAxios / attachToFetch helpers 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.

import { AccountSwitcherProvider, useAccountSwitcher } from '@ackplus/nest-auth-react';
 
<AccountSwitcherProvider config={{ baseUrl, accessTokenType: 'header' }}>
  <App />
</AccountSwitcherProvider>;
 
function Switcher() {
  const { accounts, switchAccount, removeAccount } = useAccountSwitcher();
  return (
    <ul>
      {accounts.map((a) => (
        <li key={a.accountId}>
          <button onClick={() => switchAccount(a.accountId)}>
            {/* prefer the app-supplied tenantName so identical shared-owner emails are distinguishable */}
            {a.tenantName ?? a.label ?? a.email}{a.isActive ? ' ✓' : ''}
          </button>
          <button onClick={() => removeAccount(a.accountId)}>sign out</button>
        </li>
      ))}
    </ul>
  );
}

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:

const { addAccount, setAccountMeta } = useAccountSwitcher();
 
// at add time
await addAccount(loginDto, { meta: { tenantName: 'Green Valley', label: 'Work' } });
 
// or update it afterwards
await setAccountMeta(accountId, { tenantName: 'Sunrise' });

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:

const account = await addAccount(loginDto, { meta: { tenantName } });
if (account.mustChangePassword) router.push('/change-password');

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:

import { AccountMfaRequiredError } from '@ackplus/nest-auth-client';
import { NestAuthMFAMethodEnum } from '@ackplus/nest-auth-contracts';
 
function AddAccountForm() {
  const { addAccount, completeMfa } = useAccountSwitcher();
  const [pending, setPending] = useState<AccountMfaRequiredError | null>(null);
 
  async function start(dto) {
    try {
      await addAccount(dto, { meta: { tenantName: 'Green Valley' } });
    } catch (err) {
      if (err instanceof AccountMfaRequiredError) setPending(err); // show OTP UI
      else throw err;
    }
  }
 
  async function verify(otp: string) {
    if (!pending) return;
    // verifyDto: { method, otp, trustDevice? }
    await completeMfa(pending, { method: NestAuthMFAMethodEnum.TOTP, otp });
    setPending(null);
  }
 
  return pending ? <OtpForm onSubmit={verify} /> : <LoginForm onSubmit={start} />;
}

One-shot: verify2fa consumes the code. If verification succeeds but the commit step throws, do not call completeMfa again (the code is spent) — recover with manager.commitAccount(pending.client, meta) (the manager escape hatch is on the context value). Equivalently in the vanilla client: complete verify2fa on error.client, then manager.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:

import { AddAccountGuard } from '@ackplus/nest-auth-react';
 
// /add-account — always shows the login form
<AddAccountGuard>
  <LoginForm onSubmit={(dto) => addAccount(dto, { meta: { tenantName } })} />
</AddAccountGuard>

Wire the switcher's "Add another account" button to navigate here (or to /login?add=1, gating with <AddAccountGuard adding={...}>).

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.

NestAuthModule.forRoot({
  session: { allowMultipleAccounts: true, accessTokenType: 'cookie' },
});
import { CookieAccountManager } from '@ackplus/nest-auth-client';
 
const accounts = new CookieAccountManager({ baseUrl: 'https://api.example.com' });
await accounts.ready();                  // GET /auth/accounts
await accounts.addAccount(loginDto);     // server appends this account's cookies + selector
await accounts.switchAccount(idA);       // rewrites the selector cookie — no server call
await accounts.removeAccount(idB);       // makes it active, then logs it out (server clears + promotes)

The same <AccountSwitcherProvider config={{ baseUrl, accessTokenType: 'cookie' }}> automatically uses CookieAccountManager — your switcher component is identical.

How it works under the hood:

  • List — GET /auth/accounts returns 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 via document.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.sameSite at lax/strict and secure: true in 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:

const accounts = new AccountManager({
  baseUrl,
  accessTokenType: 'header',
  fallbackClient: bootstrapClient,   // used whenever no account is active
});
 
accounts.attachToAxios(api);
 
// Read it REACTIVELY — the account index loads asynchronously, and the active
// account changes on switchAccount().
const client = useSyncExternalStore(
  (onChange) => accounts.subscribe(onChange),
  () => accounts.resolveActiveClient() ?? bootstrapClient,
);
 
<AuthProvider client={client}>{children}</AuthProvider>

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() and removeAccount() never touch the fallbackClient — 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() (also reset() on the React useAccountSwitcher()) — 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:
// plain sign-in (not the "add account" flow): start clean, then log in
await manager.reset();
await manager.addAccount(loginDto);
  • manager.discardPendingClient(pendingClient) — if you createPendingClient() (or catch an AccountMfaRequiredError) and the user abandons the flow (cancels MFA/OTP, navigates away), discard the pending client so the tokens its login wrote don't linger:
try {
  await manager.addAccount(dto);
} catch (e) {
  if (e instanceof AccountMfaRequiredError && userCancelled) {
    await manager.discardPendingClient(e.client);
  }
}

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 exposes keys() (the built-in local/session adapters do). Set reapOrphanStorageOnReady: false only if several managers share one storage and you drive un-indexed pending clients across reloads — then reap manually with discardPendingClient / 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.