Account Security

The one place the platform's identity-and-access doctrine is written down: what gets a user in, what each credential is allowed to open, and what every sensitive action demands. The mechanics live in their own docs — Passkeys for the WebAuthn ceremonies, Sealed Vault for the encryption capability — this doc owns the rules that tie them together. When a new feature needs to decide "what should this action require?", the answer comes from here.

The two doors

An account has up to two doors, and they are deliberately different:

  • The session — proves identity. Opened by password sign-in (or passkey sign-in where allowed, below). A session reads and does everything that is not sealed.
  • The vault — proves presence. Opened only by a live unlocker ceremony (a passkey assertion with user verification, a one-time recovery code, or an enrolled passphrase). Content sealed to the vault — and nothing else — is behind this door. See Sealed Vault.
Everything below follows from keeping those doors separate.

The role split: a passkey never opens both doors

A passkey never opens both the session and the vault on the same account.

  • An account without a vault may use a passkey for passwordless sign-in — there is nothing behind the second door, so the single credential guards a single door.
  • Activating a vault flips it: setup refuses to start until the account has a working password, and from then on passkey_login_verify rejects passwordless sign-in for that account. The passkey remains fully usable as a step-up confirmation and as the vault unlocker — only passwordless sign-in is withdrawn. (Mechanics: Sealed Vault § The vault-activation flip.)
The point is theft math: whoever steals the one object that unlocks the vault must still produce a different credential to get a session at all.

Step-up confirmation

Any feature can demand "re-confirm your second factor before this". Passkey and TOTP step-ups share one short-lived verified marker (session-bound, stored server-side in pks_passkey_ceremonies, kind stepup): a passkey step-up writes it via PasskeyService::verifyStepUp(), a TOTP step-up via SessionControl::stamp_second_factor(). Step-up proves the account owner is present at this keyboard now; a stolen session cookie cannot mint one.

SessionControl::require_recent_second_factor($return_url) is the shared gate a sensitive administration action calls: it returns a redirect to the /verify-stepup ceremony (which confirms with a passkey or a TOTP/backup code, stamps the marker, and returns to $return_url) when no recent confirmation exists, or proceeds otherwise. It is a no-op for an account with no second factor enrolled — there is nothing to step up with. The line the gate draws: *the vault gates plaintext redirection; the second factor gates administration. Domain security-level changes are gated this way today; the same helper is how the remaining sensitive-administration actions adopt the gate.

Fortress mandatory two-factor enrollment

A user who owns or holds a grant on a Fortress-level domain (InboundEmailDomain::maxSecurityLevelForUser()) must have a second factor enrolled that is independent of any single passkey: TOTP, or at least two passkeys (SessionControl::user_has_independent_second_factor()). One passkey alone does not satisfy it — the vault-holder password reset excludes the passkey that authorized it and demands another factor, so enrollment must guarantee that a lone stolen authenticator can never be both the reset authorizer and its own confirmation. Until a qualifying factor exists, SessionControl::must_enroll_2fa_for_fortress() blocks every page behind a redirect to /profile/security (the same session-cached, enrollment-surface pattern the admin-2FA requirement uses) — exempting only that page and /logout to avoid a loop. Enrolling a qualifying factor clears the gate immediately. The posture lookup is cached per session and dropped when the acting user changes a domain's level.

Second factor at sign-in

Whether a second factor is asked at password sign-in is the account's 2FA cadence (usr_2fa_cadence): every_login asks it on each sign-in; sensitive_only signs in password-only and defers the factor to the step-up gate above. When it is asked, the sign-in stashes a pending-login state and diverts to /verify-totp, which offers either a TOTP/backup code or a passkey step-up (login_2fa_passkey_options/_verify) — both finish through the same Login2fa::completePendingLogin(). The divert fires whenever the account holds any usable second factor (user_has_second_factor() — TOTP, or a live passkey while passkeys_enabled is on), so a passkey-only account is asked for its passkey at sign-in rather than silently skipping the second factor. Passkeys stop counting while passkey sign-in is disabled site-wide, so turning the setting off drops a passkey-only account to password-only sign-in instead of stranding it at a factor prompt it cannot complete. This is separate from the vault: a passkey used here confirms presence for the session; it does not open the vault (that is a distinct user-verification ceremony).

Separation nudge. When a passkey both signs the user in and unlocks their vault, one stolen device holds both gates. The security page warns — never blocks — steering toward a separate login factor (a phone authenticator) from the vault passkey (a laptop or hardware key).

Enrollment is guarded

Adding a credential is itself a sensitive action — a session thief must not be able to quietly enroll their own key:

  • First passkey on an account: requires the account password re-entered.
  • Additional passkeys: require a recent step-up with an existing passkey.
  • Vault unlockers (another passkey wrapping, regenerated recovery codes, a passphrase): require an open unlock window, and code regeneration and passphrase changes additionally require a recent step-up.

The unlock window

One unlocker ceremony opens the vault for a bounded window (VaultUnlock, APCu, keyed to the browser session):

  • Opens on any successful unlocker ceremony. Passkey unlocks demand device user verification (biometric or key PIN), not merely presence.
  • Extends on activity; idles out after vault_unlock_idle_minutes (default 30).
  • Ends at the idle timeout, on the explicit Lock control, or with the browser session — the window is keyed to the session and never survives it. VaultUnlock::lock() / lockAll() are the generic wipe surface policy events call.
  • Presence beacon. Presence means "on Joinery": every page includes assets/js/vault-presence.js for signed-in users, and while a window is open it stamps vault_heartbeat every ~25s (hidden tabs keep beating at the browser's throttled cadence). PublicPageBase emits a joinery-vault-window meta flag when a window is open at render time; in-page unlock ceremonies start the beacon via JoineryVaultPresence.start() or a joinery:vault-unlocked event. A read that finds the beat stale beyond VaultUnlock::HEARTBEAT_MAX_STALE_SECONDS (300s, sized above worst-case background-tab throttling) ends the window — checked at read time, no cron. So navigating anywhere on the site keeps the window alive; only a browser that is genuinely gone (closed, asleep, machine off) goes stale.
  • Network identity change. The existing IP-change guard that zeroes elevated permissions on a major address jump also ends that session's window.
  • Per-level caps. Beyond the idle timeout, a window carries caps by the user's highest mail level (VaultUnlock::capsForUser(), recorded with the window and checked at read time): Fortress ends 2h after the last content decrypt (idle) and unconditionally 24h after arming (absolute); Private carries a 7-day absolute backstop.
  • Credential events end every window everywhere (lockAll()) — the remote kill switch. A password change, a 2FA method change (disabling TOTP), app-session revocation, and a recovery-code unlock all wipe every session's window for the account and alert it. So a user whose laptop just walked away changes their password from their phone and every window dies with it.

Two-factor cadence

One account setting, two values (usr_2fa_cadence, User::two_factor_cadence()):

  • every_login (default) — the second factor is asked on each password sign-in.
  • sensitive_only — sign-in is password-only; the factor is asked at sensitive actions instead (the step-up gate above). Sound, not a loophole, because every escalation from a bare session is independently gated: sealed content needs the vault; password/email/2FA changes and recovery-code use need a step-up; routing changes need an open window. A phished password on this posture sees the mailbox's shape — counts, times, labels, placeholders — and opens nothing. The setting's own change is a step-up action, and choosing it carries the one-line consequence.

Unlockers, ranked

  • Passkey — the everyday unlocker: one tap, user verification required.
  • Recovery codes — one-time, for disasters, so the strictest path: when the account has a second factor, a recovery-code unlock requires a recent step-up regardless of the 2FA cadence (the API returns a second_factor_required flag that routes the user through /verify-stepup first). On use it ends every open window everywhere (VaultUnlock::lockAll()) and then opens one only for the recovering session, and emails a security alert to the account — so a stolen code lands the thief in a re-locked vault while the owner is notified. Consuming one drops the vault into a regenerate recommended state once fewer than 3 remain unused. Recovery codes are vault-only: they answer "give me my data," never "log me in."
  • Passphrase — optional fallback (Argon2id-derived), for accounts that want a memorized unlocker alongside hardware.
The unlocker floor: any change that would leave a vault with fewer than 1 passkey wrapping and fewer than 3 unused recovery codes is refused at the point of change — passkey revocation is vetoed (PasskeyRevocationVetoException), wrapping deletions are blocked — and the refusal names what to enroll first. Losing every unlocker loses the sealed content permanently; the floor exists so no single careless click can get an account there.

Password reset

A password reset re-issues the session, never the vault: an authorizer proves control of the account and yields a signed-in session, but sealed content still demands an unlocker ceremony the resetter cannot fake. An admin-assisted reset has the same shape — it can restore the account, and structurally cannot open the vault. Every reset is a credential event: on completion it ends every open window everywhere (lockAll()) and alerts the account — so even a hostile reset lands the attacker in a re-locked vault.

Reset authorizers. "Forgot password" offers whichever the account holds, each routed through the one completion path (PasswordResetAuthorizers::issueResetUrl() mints a single-use account code consumed by password_reset_2_logic, never anything vault-scoped):

  • Email link — the reset link mailed to the account email (and to a verified recovery address, if set). Today's flow, unchanged.
  • Passkey — a sessionless ceremony (password_reset_passkey_options/_verify), reusing the passwordless sign-in dispatch. For a vault holder the passkey is not enough on its own: the ceremony additionally requires an independent second factor (TOTP, or a passkey other than the one that authorized the reset) at /password-reset-2fa — without it a stolen authenticator could reset, sign in, and unlock with one key, the collapse the role split forbids. A vault holder with no independent factor accepts passkey-alone as their floor, consistent with declining 2FA everywhere else.
  • TOTP alone — for accounts without a vault only (password_reset_totp_logic); a vault holder is refused and steered to the passkey path before any code is verified, so a denied reset never burns a backup code. Rate-limited.
  • External recovery address — an out-of-band inbox the reset link is also sent to (usr_recovery_email, verified via recovery_verify_logic; set/removed under step-up in security_logic). Vault recovery codes are never account-reset authorizers — they answer "give me my data", never "log me in", and are only consumed by vault_unlock_recovery_logic.
Population 2 — the login email is a hosted mailbox (the circular case). Making one of the user's own hosted addresses the account (login) email would send every future reset link into the very inbox a locked-out user cannot reach. So it requires holding a non-email reset path first (a passkey, TOTP, or a verified recovery address) — enforced when the login email is changed (account_edit_logic.php) and when a hosted address is chosen at account creation (register_logic.php, via InboundEmailDomain::isHostedEmailAddress()), stated at the moment the address is chosen, not during the crisis.

Vault-gated routing settings

Sealing protects content at rest — it does nothing about a setting that redirects a sealed mailbox's future mail before it is ever sealed. A filter's "Forward to" action, an alias's destination list, or its delivery mode all act at receive time on the plaintext parse, upstream of sealing entirely — a control-plane bypass that leaves the content encryption technically intact while quietly routing around it. So once a mailbox is sealed (its single owner holds a Sealed Vault), a mutation to any of these is refused unless the owner has an open unlock window — proof of active, in-the-moment consent to the routing change, not just a permission check.

This is enforced where the mutation happens (`plugins/mailbox/logic/ admin_mailbox_filters_logic.php's _filter_require_unlock()`, admin_mailbox_alias_logic.php's _mailbox_alias_require_unlock()), not as a new platform mechanism: both call the existing VaultUnlock::isOpen($owner_id). Because that check is scoped to the calling session, it can only ever be satisfied by the owner's own logged-in session — an admin managing someone else's sealed mailbox structurally cannot open that owner's vault, so the mutation is refused with a clear message rather than a raw permission error. A domain-wide filter scope (no single owner) and a brand-new alias (no established owner/grant relationship yet) are never gated. Outbound relay SMTP settings (mailbox_forwarding_smtp_*) — repointing the relay routes future outbound plaintext through an attacker's box — are gated too: the core settings-save (adm/logic/admin_settings_logic.php) refuses a change to any setting a plugin declares in its vaultGatedSettings manifest list (VaultGatedSettings) while the acting account holds a vault with no open window, saving the rest and prompting the user to unlock and save again. The list is plugin-declared, so the closed set of reroute settings never becomes a core dependency on any one plugin.

What each action requires

ActionRequires
Sign inPassword — or passkey, only while the account has no vault
Second factor at sign-inAsked when cadence is every_login and the account holds any factor: a TOTP/backup code or a passkey step-up
Read sealed contentOpen unlock window
Open the windowUnlocker ceremony (passkey + user verification / recovery code / passphrase)
Enroll first passkeySession + password re-entry
Enroll additional passkeySession + recent step-up
Add a vault unlockerOpen window (+ step-up for codes/passphrase)
Revoke a passkeySession; refused if it breaks the unlocker floor
Rotate the vault keyLive PRF assertion from an enrolled passkey
Password resetAn authorizer — email link, passkey, TOTP (no-vault only), or verified recovery address; never opens a vault
Passkey reset on a vault accountPasskey plus an independent second factor (TOTP or a different passkey), when one is enrolled
Set / verify an external recovery addressSession + recent step-up; a hosted-domain or over-length address is refused
Change the account passwordSession + recent step-up; ends all windows + alerts
Change the account (login) emailSession + recent step-up; a hosted address needs a non-email reset path first
Regenerate 2FA backup codesSession + recent step-up
Disable 2FASession + a current TOTP/backup code; ends all windows
Change 2FA cadenceSession + recent step-up
Change a domain's security levelSession + recent second-factor step-up
Unlock the vault with a recovery codeSession + recent step-up (if a factor is enrolled); ends all other windows + alerts
Change a sealed mailbox's filters or alias routingSession + open unlock window (the owner's own)
Send as a protected identity domainOpen unlock window, via the mailbox compose path only — ambient/transactional senders are refused outright
Protect a domain / stage or cut over a DKIM rotationAdmin session + open unlock window (the key seals to the owner's vault)
Consumers of the vault (mail, chat) define their own content* policies — what they seal, what their locked state looks like — on top of these rules; see each consumer's own doc.

Native app: sessions and TOTP

The iOS member app's Security screen (ios/joinery-kit/Sources/JoineryMemberKit/SecurityScreen.swift, reached from Settings) renders app sessions and TOTP status natively, backed by two /api/v1 actions:

  • security_overview (read) — TOTP enabled flag and enrollment time, backup-codes-remaining count, the app-session list (device label, created time, last-used time, an is_current flag on the key that made the request), passkey count, and vault-active flag. It is the only read surface for ApiKey — the model gets no CRUD exposure; this payload is it.
  • security (mutation) — the same TOTP and app-session actions the web security page uses: start_enable / confirm_enable / cancel_enable / regenerate_backup_codes / disable for TOTP, revoke_app_session / revoke_all_app_sessions for sessions. The enable flow's response carries the provisioning_uri (an otpauth:// string) the native screen renders as a QR with CIFilter.qrCodeGenerator — the same value the web page's SVG QR encodes.
Revoking the session key that made the request signs the native app out immediately through the client's existing 401 handling (SessionController.sessionInvalidatedHandler) rather than leaving a dead session in the UI.

Passkey and Sealed Vault management stay web-only. WKWebView does not expose platform WebAuthn to an embedded webview, so the only possible native path is ASAuthorizationPlatformPublicKeyCredential (with PRF for the vault) — a policy decision of its own (passkey_register_options, passkey_rename, and passkey_revoke currently require the browser-session credential; see API § Two authorization axes). The native Security screen shows passkey count and vault status from security_overview and links out to the web security page for management; native enrollment and revocation belong to a dedicated future mobile_native_security_credentials spec.