Security Dashboard
Per-user security settings at /overview/settings/security.
Two-Factor Authentication
UI component: src/app/overview/settings/security/two-factor.tsx.
Enable flow
- Click Enable 2FA →
POST /api/internal/two-factor/setup. - Page shows QR (rendered via
api.qrserver.comfrom theotpauth://URI) plus the raw base32 secret. - User adds to authenticator app, enters the 6-digit code, submits.
POST /api/internal/two-factor/verify { code }— server validates TOTP, hashes and stores 8 backup codes, flipsenabled: true.- Page displays backup codes in a one-time yellow panel ("I've saved them" to dismiss).
Disable flow
- Click Disable 2FA → panel asks for password.
POST /api/internal/two-factor/disable { password }— server bcrypt-compares, deletes the row on success.
Backup codes
- Generated at verify time.
- Each is 10 hex chars (40 bits).
- Stored as bcrypt hashes in
two_factor_secrets.backup_codes(text array). - Login comparison iterates and
bcrypt.compares — used hash is filtered out. - Rendered plaintext only once. No way to re-reveal — user must disable and re-enable if lost.
Login
When 2FA is enabled, the credentials provider:
- Accepts a third field
totpalongside email + password. - No
totp→ throws"2FA_REQUIRED"so client can re-prompt. - TOTP match (window: ±1 × 30s) → success.
- Backup code match (bcrypt) → success + invalidate that code.
Password reset invalidates sessions
See password-reset.md. On successful reset the user's session_version increments; any JWT issued before that version is rejected on the next request.
Session version check
Implemented in jwt callback of src/lib/auth/options.ts:
const [u] = await db.select({ sessionVersion: users.sessionVersion })...
if (token.sessionVersion !== undefined && token.sessionVersion !== current) {
return { userId: "", orgId: "", sessionVersion: current };
}
token.sessionVersion = current;
Stale tokens return an empty-identity token, which the session callback maps to an empty session → the next requireSession() returns null → user is redirected to login.
SSO enforcement (Enterprise)
Owners of orgs with an active SAML connection can require SSO-only login —
toggle on /overview/sso, persisted as sso_connections.enforce_sso,
enforced in src/lib/auth/sso-enforcement.ts.
Binding rules — each closes a specific abuse path:
- Existing users are bound only by orgs they're a member of. A stranger
org pinning
gmail.comcannot lock other tenants' users out. - Org owners are exempt (break-glass): a broken IdP can't lock out the person who can turn enforcement off. Owners should keep 2FA on.
- Unknown emails are bound by a domain-pinned connection only when the org has a verified sending domain with the same name — ownership proof, so the JIT-race protection can't block public-domain signups platform-wide.
Rejection surfaces as SSO_REQUIRED — the credentials provider throws it
before the password compare (correct passwords get the same answer), and the
OAuth signIn callback redirects to /login?error=SSO_REQUIRED. The SAML
token-exchange arm is exempt: it is the SSO path.
Known tradeoff: for domain-less connections, SSO_REQUIRED confirms the
email belongs to a member of an SSO-enforced org. This matches GitHub/Stripe
behavior (the SSO hint must be shown pre-auth to be useful) and is bounded by
the login rate limit.
Enabling enforcement bumps session_version for all members except the
acting owner — existing password/OAuth sessions end immediately; members
re-auth through the IdP.
Managed via PATCH /api/internal/sso { enforce_sso } (owner + enterprise
plan), audited as org.security_policy_updated.
Session policies (Enterprise)
Org-level columns on organizations, managed from the same SSO page +
PATCH /api/internal/sso:
session_max_age_hours— hard ceiling on session lifetime. The JWT callback stampsloginAtat fresh login and rejects tokens older than the policy on refresh (cached alongside the session-version check, so no extra round-trip). Tokens minted before the feature carry nologinAtand age out at their next fresh login.max_sessions_per_user— concurrent-device cap. Enforced at login inenforceConcurrentSessionCap: the fresh session plus thecap - 1most recently seen sessions survive; older ones are revoked and their session cache entries invalidated (felt within 30 s).
Both policies evaluate the strictest value across all of the user's org memberships — a personal default org doesn't exempt anyone from their employer's policy.
SP metadata + SCIM groups (Enterprise)
- SP metadata:
GET /api/auth/saml/metadataserves importable SAML SP metadata XML (entity ID, ACS URL, POST binding,WantAssertionsSigned). IdP admins import the URL instead of hand-copying fields; surfaced on/overview/sso. - SCIM Groups:
/api/scim/v2/Groups(GET/POST + GET/PATCH/PUT/DELETE by id) accepts Okta/Azure AD group push — stored inscim_groups+scim_group_members. Groups are inert until an owner maps them to a role on the SSO page (PATCH /api/internal/scim/groups, audited asscim.group.mapped). - Mapping policy (
src/lib/auth/scim-groups.ts): highest mapped role across a user's groups wins; owners are never touched;owneris not mappable — SCIM can never grant ownership (enforced at the zod layer, the rank table, and a DB CHECK constraint). Losing all mapped groups leaves the role as-is (deprovisioning stays the Users endpoint's job). - Known tradeoff: for non-owners in mapped groups, IdP group state is the source of truth — a manual promotion is overwritten on the next sync. That's the point of directory sync; promote via IdP groups, not the dashboard, once mapping is on.
- Directory
default_roleis capped atmember/developer/viewer— the Users endpoint also clamps legacyownerdirectories tomemberat provision time.