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.

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:

  • ListGET /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>

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);
  }
}
  • 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.