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:

  1. click logout
  2. session destroyed, cookie cleared
  3. navigate to login
  4. enter new handle
  5. redirected to PDS - but PDS auto-approves if "remember this account" was checked
  6. 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#

store multiple sessions server-side, switch by rotating which one is "active."

how it works:

  1. user logs in with account A - session created, cookie set
  2. user clicks "add account" - redirected with prompt=login
  3. user logs in with account B - second session created
  4. both sessions stored in database, linked by a "session group" or stored as array in encrypted cookie
  5. 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 with prompt=login, links to existing session group
  • POST /auth/switch-account - sets is_active=false on current, is_active=true on target
  • GET /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:

  1. "switch account" button triggers OAuth with prompt=select_account
  2. current session destroyed before redirect
  3. PDS shows account picker
  4. 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#

  1. session expires for one account - remove from group, notify if it was active
  2. scope upgrade needed - only affects the active session, not others in group
  3. cross-tab sync - BroadcastChannel already exists; extend to broadcast account switches
  4. queue state - queue is global, not per-account (music keeps playing during switch)
  5. mobile (ProfileMenu) - same UX, adapted for touch

implementation phases#

phase 1: prompt parameter support#

  1. fork update: add prompt param to start_authorization()
  2. backend: pass prompt to SDK in /auth/start
  3. frontend: "sign in with different account" uses prompt=login

outcome: users can force re-auth, but still single-session.

phase 2: session groups#

  1. database migration for session groups
  2. /auth/add-account endpoint
  3. /auth/switch-account endpoint
  4. modify /auth/me to return account list
  5. frontend account switcher UI

outcome: full multi-account experience.

phase 3: polish#

  1. account avatars in switcher
  2. keyboard shortcut for quick-switch (Cmd+Shift+A?)
  3. "switch to" option in artist page when viewing own other account
  4. 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#

  1. should we limit accounts per group? (suggest: 5 max)
  2. what about developer tokens? - probably exclude from session groups, they're standalone
  3. how to handle account picker on login page? - show known accounts if cookie exists but session expired?
  4. 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'
  • Schema type defines all persisted state
  • currentAccount stores 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 fields
  • src/state/session/reducer.ts - state transitions
  • src/state/session/agent.ts - agent creation and token refresh
  • src/components/dialogs/SwitchAccount.tsx - switcher UI
  • src/lib/hooks/useAccountSwitcher.ts - switching logic
  • src/components/AccountList.tsx - account list rendering

their UX patterns#

  1. account list items:

    • 48x48 avatar
    • display name + @handle
    • green checkmark (not chevron) on current account
    • "logged out" italic label for expired sessions
  2. switching flow:

    • if tokens valid: resumeSession() silently
    • if tokens expired: show login form for that specific account
    • race condition protection via pendingDid state
  3. logout distinction:

    • logoutCurrentAccount: clears tokens, account stays in list
    • logoutEveryAccount: clears everything, back to login
  4. cross-tab sync:

    • synced-accounts action handles changes from other tabs
    • needsPersist flag 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:

  1. check if refreshJwt exists and is not expired
  2. if valid: restore session, attempt background refresh
  3. 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 useProfilesQuery to batch-fetch profile data for all accounts
  • isJwtExpired() helper determines "logged out" state
  • pendingDid disables interaction during switch (pointerEvents: 'none')
  • profiles fetched by DID array, matched back to accounts

references#