multi-account experience#
status: design draft issue: #583 date: 2026-01-03
problem#
users with multiple ATProto identities (personal account, artist alias, band account) cannot easily switch between them. the current flow:
- click logout
- session destroyed, cookie cleared
- navigate to login
- enter new handle
- redirected to PDS - but PDS auto-approves if "remember this account" was checked
- no way to force account selection or fresh login
the PDS remembers the client and auto-signs in, making multi-account workflows frustrating.
ATProto OAuth prompt parameter#
the AT Protocol OAuth spec supports a prompt parameter with three modes:
| value | behavior |
|---|---|
login |
forces re-authentication, ignoring remembered session |
select_account |
shows account selection instead of auto-selecting |
consent |
forces consent screen even if previously approved |
prerequisite: our atproto SDK fork needs to accept prompt in start_authorization().
status: PR opened (zzstoatzz/atproto#8)
# new signature
async def start_authorization(
self,
handle_or_did: str,
prompt: Literal["login", "select_account", "consent", "none"] | None = None
) -> tuple[str, str]
design options#
option A: session stack (recommended)#
store multiple sessions server-side, switch by rotating which one is "active."
how it works:
- user logs in with account A - session created, cookie set
- user clicks "add account" - redirected with
prompt=login - user logs in with account B - second session created
- both sessions stored in database, linked by a "session group" or stored as array in encrypted cookie
- user menu shows both accounts, click to switch active
session storage approaches:
| approach | pros | cons |
|---|---|---|
| encrypted cookie array | no db schema change, stateless switching | cookie size limits (~4KB), complex encryption |
| session groups table | clean relational model, unlimited accounts | db schema migration, additional queries |
| localStorage + session_id | simple to implement | XSS-vulnerable, breaks HttpOnly security model |
recommendation: session groups table - maintains security model, clean data relationships.
schema sketch:
-- new table: links multiple sessions as a group
CREATE TABLE session_groups (
group_id UUID PRIMARY KEY,
created_at TIMESTAMP DEFAULT NOW()
);
-- modify user_sessions to reference group
ALTER TABLE user_sessions ADD COLUMN group_id UUID REFERENCES session_groups(group_id);
ALTER TABLE user_sessions ADD COLUMN is_active BOOLEAN DEFAULT true;
-- index for fast group lookups
CREATE INDEX idx_sessions_group ON user_sessions(group_id);
backend changes:
POST /auth/add-account- starts OAuth withprompt=login, links to existing session groupPOST /auth/switch-account- setsis_active=falseon current,is_active=trueon targetGET /auth/me- returns active session info + list of other accounts in group- cookie still holds single
session_id- backend looks up group from it
frontend changes:
- user menu shows current account + "add account" option
- if multiple accounts in group, show account switcher
- clicking different account calls
/auth/switch-account
option B: browser-managed (simpler, less ideal)#
don't store multiple sessions - just make switching easier.
how it works:
- "switch account" button triggers OAuth with
prompt=select_account - current session destroyed before redirect
- PDS shows account picker
- user picks account, new session created
pros: minimal backend changes, no schema migration cons: loses previous session entirely, can't "quick switch" back
option C: parallel windows (no code changes)#
educate users to use private/incognito windows for different accounts.
pros: zero implementation effort cons: poor UX, not a real solution
UX flows#
when logged in (single account)#
┌─────────────────────────────┐
│ @artist.bsky.social ▼ │
├─────────────────────────────┤
│ ⬚ portal │
│ ⚙ settings │
│ ───────────────────────── │
│ + add account │ ← new
│ ───────────────────────── │
│ ⎋ logout │
└─────────────────────────────┘
when logged in (multiple accounts)#
┌─────────────────────────────┐
│ @artist.bsky.social ▼ │ ← active account
├─────────────────────────────┤
│ ⬚ portal │
│ ⚙ settings │
│ ───────────────────────── │
│ ○ @personal.bsky.social │ ← switch to this
│ ○ @band.music │ ← switch to this
│ + add account │
│ ───────────────────────── │
│ ⎋ logout │ ← logs out active only
│ ⎋ logout all │ ← clears entire group
└─────────────────────────────┘
logout behavior#
question: what should "logout" do with multiple accounts?
| option | behavior |
|---|---|
| logout active only | removes current session, auto-switches to next account in group |
| logout all | destroys entire session group, back to login page |
recommendation: default to logout active, provide "logout all" as separate option.
edge cases#
- session expires for one account - remove from group, notify if it was active
- scope upgrade needed - only affects the active session, not others in group
- cross-tab sync - BroadcastChannel already exists; extend to broadcast account switches
- queue state - queue is global, not per-account (music keeps playing during switch)
- mobile (ProfileMenu) - same UX, adapted for touch
implementation phases#
phase 1: prompt parameter support#
- fork update: add
promptparam tostart_authorization() - backend: pass prompt to SDK in
/auth/start - frontend: "sign in with different account" uses
prompt=login
outcome: users can force re-auth, but still single-session.
phase 2: session groups#
- database migration for session groups
/auth/add-accountendpoint/auth/switch-accountendpoint- modify
/auth/meto return account list - frontend account switcher UI
outcome: full multi-account experience.
phase 3: polish#
- account avatars in switcher
- keyboard shortcut for quick-switch (Cmd+Shift+A?)
- "switch to" option in artist page when viewing own other account
- notification badge per account (future)
security considerations#
- no localStorage for session IDs - maintains HttpOnly security model
- session group isolation - groups are per-browser, not per-user (different devices = different groups)
- cookie still single value - one active session_id, backend resolves group membership
- logout clears cookie regardless - even with session groups, logout destroys the cookie
open questions#
- should we limit accounts per group? (suggest: 5 max)
- what about developer tokens? - probably exclude from session groups, they're standalone
- how to handle account picker on login page? - show known accounts if cookie exists but session expired?
- mobile app (future) - will need equivalent session group storage in secure keychain
bluesky implementation study#
studied bluesky's open-source client (social-app) to inform our design.
their architecture#
persistence layer (src/state/persisted/):
- uses AsyncStorage with single key
'BSKY_STORAGE' Schematype defines all persisted statecurrentAccountstores only DID (lightweight reference)- full account data lives in
accounts[]array
session state shape:
interface SessionState {
accounts: SessionAccount[] // all accounts, even expired ones
currentAccount: SessionAccount // active account reference (DID only)
hasSession: boolean
}
interface SessionAccount {
did: string
handle: string
service: string // PDS URL
accessJwt?: string // may be empty/expired
refreshJwt?: string // may be empty/expired
email?: string
emailConfirmed?: boolean
emailAuthFactor?: boolean
pdsUrl?: string
active?: boolean
status?: 'takendown' | 'suspended' | 'deactivated'
}
reducer actions (src/state/session/reducer.ts):
type Action =
| { type: 'switched-to-account'; agent: BskyAgent; did: string }
| { type: 'removed-account'; did: string }
| { type: 'logged-out-current-account' }
| { type: 'logged-out-every-account' }
| { type: 'synced-accounts'; accounts: SessionAccount[]; currentAccount?: SessionAccount }
| { type: 'received-agent-event'; event: SessionEvent }
| { type: 'partial-refresh-session'; patch: Partial<SessionAccount> }
key files:
src/state/persisted/schema.ts- persistence schema with account fieldssrc/state/session/reducer.ts- state transitionssrc/state/session/agent.ts- agent creation and token refreshsrc/components/dialogs/SwitchAccount.tsx- switcher UIsrc/lib/hooks/useAccountSwitcher.ts- switching logicsrc/components/AccountList.tsx- account list rendering
their UX patterns#
-
account list items:
- 48x48 avatar
- display name + @handle
- green checkmark (not chevron) on current account
- "logged out" italic label for expired sessions
-
switching flow:
- if tokens valid:
resumeSession()silently - if tokens expired: show login form for that specific account
- race condition protection via
pendingDidstate
- if tokens valid:
-
logout distinction:
logoutCurrentAccount: clears tokens, account stays in listlogoutEveryAccount: clears everything, back to login
-
cross-tab sync:
synced-accountsaction handles changes from other tabsneedsPersistflag prevents sync cycles
what we can adopt#
| pattern | bluesky | plyr.fm adaptation |
|---|---|---|
| account list with avatars | 48x48 + name + handle | same, with our design tokens |
| checkmark on active | green circle-check | use var(--success) |
| expired session label | "logged out" italic | same |
| logout vs logout-all | two distinct actions | same approach |
| token-based resume | client-side jwt check | server-side via session group |
| cross-tab sync | BroadcastChannel | extend existing player sync |
key difference#
bluesky stores tokens client-side (react native app). we store sessions server-side (HttpOnly cookies). our session group approach achieves the same UX with better web security.
token refresh strategy#
bluesky's createAgentAndResume() flow:
- check if
refreshJwtexists and is not expired - if valid: restore session, attempt background refresh
- if expired: show login form for that account (no silent refresh possible)
for plyr.fm, our server-side sessions handle refresh differently:
- refresh happens server-side via atproto SDK
- client never sees tokens
- "expired" means session row deleted or OAuth refresh failed
account list UI details#
from AccountList.tsx:
- uses
useProfilesQueryto batch-fetch profile data for all accounts isJwtExpired()helper determines "logged out" statependingDiddisables interaction during switch (pointerEvents: 'none')- profiles fetched by DID array, matched back to accounts
references#
- ATProto OAuth spec - prompt parameter handling
- bluesky social-app - multi-account reference implementation
- persisted/schema.ts - account schema
- session/reducer.ts - state machine
- AccountList.tsx - UI component
- issue #583 - original feature request
- PRs #578, #582 - confidential OAuth client context
- atproto SDK fork - prompt parameter support (merged)