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

  1. Click Enable 2FAPOST /api/internal/two-factor/setup.
  2. Page shows QR (rendered locally in the browser with the qrcode package from the otpauth:// URI — the seed never leaves the app) plus the raw base32 secret.
  3. User adds to authenticator app, enters the 6-digit code, submits.
  4. POST /api/internal/two-factor/verify { code } — server validates TOTP, hashes and stores 8 backup codes, flips enabled: true.
  5. Page displays backup codes in a one-time yellow panel ("I've saved them" to dismiss).

Disable flow

  1. Click Disable 2FA → panel asks for password.
  2. 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 totp alongside 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.

Trusted devices do not skip this. The trusted-device cookie waives the emailed login code only; the TOTP branch in authorize() runs regardless.

Which doors challenge TOTP

Enrolling an authenticator has to mean the same thing whichever button the member presses on the login page. It did not: authorize() asks for the code, and Google/GitHub simply did not, so the second factor was bypassable by choosing the other door.

Door Second factor Where
authorize() (password) asked inline — 2FA_REQUIRED until a code is supplied src/lib/auth/options.ts
Google / GitHub asked after sign-in: the session is issued and confined jwt callback → src/proxy.ts
samlToken arm exempt inside the SSO org only marked like an OAuth door; waived by src/proxy.ts

Google/GitHub. NextAuth v4 exposes no hook to interleave a second factor into the OAuth callback, and refusing the sign-in outright is a lockout — findOrCreateUser never sets a password hash, so an OAuth-only member turned away there has no other door at all. So the shape is the one the require_2fa mandate already uses: the jwt callback sets token.mustVerify2fa on a fresh OAuth login when the member has TOTP enabled, and src/proxy.ts confines the session to /overview/settings/security (which renders the challenge form from the flag) plus POST /api/internal/two-factor/challenge. Every other page redirects there; every other internal call is refused 403 2FA_CHALLENGE_REQUIRED, including the reads — the mandate protects the org's data, so /api/internal/messages and /api/internal/org/export are as closed as the mutations. Only the dashboard chrome's own lookups (/api/internal/org, /api/internal/projects, /api/internal/sessions, GET only) stay open.

The challenge endpoint accepts exactly what authorize() accepts — a 6-digit code in the same ±1 window, or a backup code consumed by the same atomic array_remove — shares the same login_2fa limiter bucket (5 / 15 min, keyed on the user, so alternating doors buys no extra guesses), and emits the same user.login_failed / stage 2fa audit row on a refusal. It records the clearance on user_sessions.mfa_verified_at; the panel then calls the NextAuth update() that re-runs the jwt callback, which reads that column and drops the flag. Same hand-off enrollment makes through two_factor_secrets.enabled — an API route cannot re-issue a token, so the fact has to live somewhere the callback can find it.

The enrollment endpoints are deliberately not opened to a challenged session: ../setup 409s while 2FA is on anyway, and ../verify mints a fresh secret and a fresh set of backup codes, so reaching it without having proven the current factor would let a stolen OAuth cookie replace the very thing it is being asked to present. Trusted-device cookies do not skip the challenge either, mirroring the credentials path above.

Failure behaviour is the reverse of the mandate's, on purpose. The mandate fails closed (an unreadable lookup confines the session) because enrollment stays reachable — the member can satisfy it and move on. A challenge cannot be satisfied while the database is unreachable, since the write that clears it runs against the same database as the read, so both the "is this member enrolled?" lookup and the clearance check fail open with logError (auth.oauth_2fa.challenge_check_failed). Degrading to "not challenged" is the behaviour that shipped before the flag existed; locking a member out of their account is not.

SAML is exempt, in the org whose IdP signed the member in and nowhere else. That org delegated authentication — second factor included — to its IdP, so challenging again is a parallel factor Sendoka asks for on top of the one the org already trusts and administers. No other org delegated anything. The exemption used to hang off a bare token.viaSaml flag, which made it user-global: a session minted by one verified-domain org's IdP walked into every OTHER org the member belonged to — past those orgs' require_2fa and past the member's own enrolled authenticator — one org switch away.

Mechanically the exemption is now applied where the flags are read, not where they are set, because whether it holds depends on the org the member is looking at right now and that changes without the jwt callback re-running:

  • the ACS mints the one-shot token as {orgId}.{32 random bytes} and drops the org-switcher cookie for that org, so the member lands in it;
  • authorize() matches the whole string against users.saml_login_token — the prefix is exactly as unforgeable as the token — and records it as token.samlOrgId;
  • the jwt callback derives mustEnroll2fa / mustVerify2fa for SAML sessions like any other, and samlExemptsTwoFactor (src/lib/auth/saml-scope.ts) waives them in src/proxy.ts and in the session mirror while the active org is samlOrgId. A SAML session that records no org — minted before the prefix existed — is waived nowhere, which costs it one challenge rather than re-opening the bypass.

The same delegation still exempts SAML from the phone gate below outright, where there is no per-org state to scope against.

Residual: an OAuth-only member who loses both their authenticator and their backup codes now has no self-serve way back in — /forgot-2fa mails a recovery link and /api/auth/recover-2fa requires a password to confirm it, which that account does not have. The panel says so rather than linking a page that would refuse them. That is inherent to asking for the factor at all; the alternative is the bypass.

Phone verification at signup

Gated entirely on SYSTEM_SMS_FROM. Unset, no phone is collected, registration behaves as it always did, and the sign-in gate stops applying — every path reads phoneVerificationEnabled(), so the switch works in both directions. Gating sign-in on the row while gating recovery on the flag made it one-way: clearing the variable after an SMS incident left every mid-signup account refused at login with the endpoints written to unstick them disabled by the same switch, and nothing anywhere clears users.phone.

Set, phone becomes required at signup, and an account with users.phone set and users.phone_verified null is refused at every door:

Door Behaviour
authorize() (password) throws PHONE_UNVERIFIED, which the login form turns into a phone-challenge
signIn callback (Google/GitHub) bounces to /login?error=PHONE_UNVERIFIED
samlToken arm exempt by design — the IdP and the org vouch for the identity, same as the 2FA mandate exemption

The OAuth door matters: enforced only in authorize(), the gate was one click wide. An account that abandoned the SMS step could take "Continue with Google" on the same page that had just refused it and come out with a full session, able to mint sok_live_* keys with phone_verified still null. Refusing there is a bounce rather than a lockout, because only /api/auth/register ever sets a phone — so every account it refuses has a password, and the password door leads back to verification.

Both OAuth gates look the account up by a lowercased address, and that is load bearing rather than tidiness. Registration stores email lowercased into a case-sensitive unique index, while a provider returns it as the user typed it — so on the raw value a mixed-case signup missed its own row, both gates read the lookup as "no such account" and passed, and findOrCreateUser then missed it too and provisioned a SECOND account with email_verified stamped. That is a full session on a duplicate row, which is exactly the hole these gates close.

Flow: POST /api/auth/register stores the number and texts a 6-digit code, then POST /api/auth/verify-phone confirms it and mails the email code. No trusted-device cookie is minted at registration, and none at the phone step for a signup that still owes its address — the auto-sign-in has nothing to carry until both proofs are in, so it is minted at POST /api/auth/verify-email-code. See Email verification at signup.

Design points worth keeping:

  • The gate sits AFTER the password compare. Every check above it in authorize() is careful to answer identically for a real and a missing account; throwing before the compare would have handed an unauthenticated caller a distinct error meaning "this address is registered".
  • The pending cookie carries the phone_verifications row id, not the user id, and never a body-supplied value. User ids are not secret — they appear in audit metadata and member lists — so a cookie holding one was a container an attacker could simply mint, burning a victim's five attempts and then their hourly resend budget so they could not unlock themselves. A row id is a 144-bit nanoid, and because it is a real row the server checks its age, so the pending TTL is enforced server-side rather than only by the cookie's maxAge. Every successful send re-issues it, so a resend renews the window it needs.
  • The resend endpoint reads the number from the user row, never the request. Accepting a phone in the body would make it an open SMS relay and would let a third party redirect a pending signup's codes to their own handset.
  • Only sha256(code) is stored, with an attempt cap. The cap is applied in ONE statement — attempts is incremented by the database with the ceiling in the WHERE — because a read-modify-write is not a counter: concurrent guesses all read the same value and all wrote back the same increment, so five tries against a ~1e6 space was really as many tries as the caller had IPs. The bump is a CASE, so a correct code never spends an attempt. login_codes uses the identical shape.
  • The send happens BEFORE the supersede. Superseding first meant a failed resend destroyed the working code already in the user's hand and returned nothing; the supersede and the insert then go out as one transaction, so a crash between them cannot leave an account with zero active codes.
  • Registration spends the send tokens before it writes anything. The account, org, default project and sandbox domain commit in one transaction, and sign-in is blocked until the code is confirmed — so a rate-limit refusal discovered afterwards produced an account that could never sign in and permanently squatted its email address. That was reachable by spending a victim's three hourly tokens on their number before they signed up, and by every signup during an Upstash outage, since strict mode fails closed.
  • users.phone is deliberately not unique. Uniqueness would limit one number farming many accounts, but it turns registration into a phone-enumeration oracle for an identifier people cannot rotate. Rate limits bound the farming case instead.
  • Abandoned signups are recoverable via POST /api/auth/phone-challenge, which re-issues the pending cookie. Without it, closing the tab mid-verification is a permanent lockout. It is an unauthenticated password door, so it re-checks everything sign-in checks, in the same order: the shared login / login_email buckets (same action names, so attempts count against the account's sign-in budget rather than a second uncounted allowance), ssoEnforcedForEmail, and the emailed login code as a mandatory first factor before the bcrypt compare. Keeping only the compare made it a password oracle against any named address with no account-side ceiling at all. It answers with one generic 400 for every refusal, and deliberately does not echo the phone number back.
  • Codes are reaped. /api/cron/retention deletes phone_verifications rows older than 7 days. Each holds a number in cleartext and is dead within ten minutes, and it is the only erasure path they have — an abandoned signup can never sign in, so the session-authenticated account-deletion route can never reach them.

SMS pumping controls

Registration texts a paid message to an attacker-chosen number, which is the classic toll-fraud target. Four ceilings, all spent before a code is minted or a message billed:

Dimension Limit Purpose
phone 3 / hour one target handset
IP 10 / hour one host
country calling code 200 / hour pumping concentrates on a single high-cost destination
global 500 / hour (PHONE_CODE_GLOBAL_HOURLY_LIMIT, 0 = kill switch) the circuit breaker — the only ceiling an attacker cannot widen

The first three are all widened by adding inputs: fresh numbers, proxy IPs, more countries. A run spread thin across many destinations paid 200/hour in each one with nothing counting the total, so the global bucket is what actually caps the bill.

The country bucket must never split one country across keys, since each fragment would get its own allowance; merging two countries is merely stricter, so an unrecognised prefix falls back to three digits. A tripped ceiling logs phone_code.rate_limited with the dimension, which is the signal a pumping run is underway.

Destinations are allowlisted, not merely well-formed. isServableDestination requires the number to start with a dial code that appears in lib/countries.ts, and additionally refuses the NANP premium ranges (900, 976, and the 5XX personal-communication codes) that sit inside the allowed +1. The E.164 shape check alone accepted +882/+883 (international networks), +870 (Inmarsat) and +8816 (Iridium) — dollars a message, unreachable from the picker, and each with its own untouched per-country bucket.

The allowlist half of that check applies to a number being ACCEPTED, not to one already stored: canDeliverTo(n, { stored: true }) — used by resend and phone-challenge, which read users.phone back out — keeps the premium-range and DLT refusals but skips list membership. Re-applying it made lib/countries.ts a lockout switch, since nothing clears users.phone: remove a dial code and every account holding such a number is refused by both recovery endpoints while sign-in keeps refusing too, leaving a manual UPDATE on the shared database as the only remedy. No cost hole opens, because users.phone is only ever written by a path that ran the strict check first.

+91 is refused at signup while DLT_ENFORCE is on — providers/sms.ts throws for India without a registered DLT template, and no system template exists for this message. Checked before the account row is written, so the signup fails cleanly instead of leaving an account that can never verify.

Email verification at signup

The other half of the signup gate, and mandatory: registration mails a 6-digit code and authorize() throws EMAIL_UNVERIFIED until users.email_verified is stamped. Same doors as the phone gate — the credentials provider throws, the signIn callback bounces Google/GitHub to /login?error=EMAIL_UNVERIFIED, and the SAML arm stays exempt for the same reason.

Unlike the phone gate there is no master switch: SES is a hard dependency of the product, so there is no configuration in which the step is skipped.

  • Scoped to users.signup_source, and that scope is load-bearing. Gating on email_verified alone would have locked out most of the existing user base: sendVerificationEmail built its link as <baseUrl>/verify-email?token=…, where no page has ever existed, so every verification mail ever sent answered 404 and the column was unreachable for password signups. Only self-serve registration and OAuth set signup_source, so invited, SAML and SCIM members — vouched for by an org or an IdP, and with no recovery path built for them — are outside the gate too.
  • The gate sits AFTER the password compare, for the same oracle reason the phone gate does.
  • The pending cookie carries the email_verifications token, which is the row's own 48-char primary key and never a body-supplied value — same reasoning as sendoka_pv. The token is never mailed for a code row, so the cookie is the only place it exists outside the database.
  • Code rows and link rows are not interchangeable. A 6-digit code has a ~1e6 space and is defended by attempts (capped in the WHERE, CASE-guarded so a correct code spends nothing) plus a 10-minute TTL; a 48-char link token is defended by entropy and has no attempt cap. So verifyEmailCode refuses link rows and consumeVerificationToken refuses code rows — otherwise six digits would buy unlimited guesses against a capless row, and a leaked cookie could be replayed as a link that skips the wizard step and its trusted-device mint.
  • The row is inserted BEFORE the mail, and the supersede runs after. Insert first so a provider failure still leaves a row for the pending cookie to name — otherwise "Send a new code", the only control on that screen, answers "session expired". Supersede last so a failed send cannot retire a code the user is already holding.
  • The resend endpoint reads the address from the user row, never the request: accepting one in the body would make it an open mail relay and let a third party redirect a pending signup's codes to their own inbox.
  • Registration spends the send token before it writes anything when the emailed code is the only step, for the same reason the SMS path does — an account whose code can never go out cannot sign in and still squats the address.
  • Abandoned signups are recoverable via POST /api/auth/email-challenge, the twin of phone-challenge down to the rate-limit buckets, the SSO check, the emailed login code as a mandatory first factor before the compare, and the single generic 400 for every refusal. It applies the gates in authorize()'s ORDER too, refusing while the phone step is owed — otherwise the second signup proof was completable while the first was outstanding, and the trusted device minted at the end of it would have carried the first-factor exemption on one proof of two.
  • Both challenge routes spend login_challenge, not login. Sharing authorize()'s per-(email, ip) bucket read as "recovery counts against the sign-in budget", but the login form POSTs to a challenge route automatically the moment the gate throws, so one user action spent two of five tokens and the third attempt 429'd at sign-in before reaching the only endpoint that can unstick the account. Sign-in's own ceiling is unchanged; the loose login_email bucket still counts both doors.
  • Codes are reaped. /api/cron/retention deletes email_verifications rows older than 7 days — the same erasure argument as phone_verifications, and before this nothing reaped that table at all.

Ceilings: email_code:email 5/hour and email_code:ip 10/hour on sends, verify_email:ip 20/15min on guesses. No per-country or global breaker — mail does not carry the toll-fraud exposure that makes those necessary for SMS.

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.

A reset — and 2FA recovery, which mirrors it — cuts three more doors the version bump does not reach: user_sessions rows are marked revoked (so the Devices panel agrees instead of listing dead sessions as active), unrevoked trusted devices are revoked (they skip the emailed login code), and the account's OAuth refresh tokens are revoked together with the api_keys rows those grants mintedvalidateApiKey checks nothing but revoked/expired on the key itself, so a grant consented during a compromise would otherwise keep minting live sok_live_* keys for 30 days. The OAuth sweep runs last on both routes, after the session-cache eviction and after the audit row / "2FA disabled" notice, so a failure there cannot discard the rest of the compromise response.

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

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.com cannot 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.

The unauthenticated POST /api/auth/login-code pre-check uses ssoEnforcedByDomain only (domain-pinned + verified ownership, same answer for every address at the domain). Membership-only (domain-less) enforcement stays in authorize() / ssoEnforcedForEmail — returning sso_required from login-code for that path would require a user row and become an account-existence oracle. Members of domain-less SSO orgs may receive a login code and then hit SSO_REQUIRED at password submit; that is intentional.

Known tradeoff: for domain-less connections, SSO_REQUIRED at password submit 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-only, all plans), audited as org.security_policy_updated.

Session policies

Org-level columns on organizations, managed from the same SSO page + PATCH /api/internal/sso. The Security policy card on /overview/sso renders for every org — no SAML connection required; only the enforce_sso toggle is SAML-bound and hidden (and refused by the route with a 409) until a connection exists:

  • session_max_age_hours — hard ceiling on session lifetime. The JWT callback stamps loginAt at 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 no loginAt and age out at their next fresh login.
  • max_sessions_per_user — concurrent-device cap. Enforced at login in enforceConcurrentSessionCap: the fresh session plus the cap - 1 most recently seen sessions survive; older ones are revoked and their session cache entries invalidated (felt within 30 s).
  • require_2fa — org 2FA mandate. Turning it ON bumps session_version for every member without an enabled 2FA secret (the acting owner excepted, so the toggle doesn't self-logout); at their next login the JWT callback sets token.mustEnroll2fa and src/proxy.ts confines the session to the enrollment flow until an authenticator is enrolled. Applies to owners too. SAML sessions are exempt only while the active org is the one whose IdP signed them in (above) — this org's mandate binds a session another org's IdP minted. Changes are audited as org.security_policy_updated with the new value in metadata.

All three policies evaluate across the user's org memberships — the session policies take the strictest value and the mandate binds if any membership requires it (orgMandates2faForUser) — a personal default org doesn't exempt anyone from their employer's policy.

SP metadata + SCIM groups

  • SP metadata: GET /api/auth/saml/metadata serves 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.
  • The ACS enforces those values back. A signature only proves the IdP signed the assertion, not that it signed it for us: orgs federate one IdP with several SPs, so an assertion minted for a different SP was a valid Sendoka login for anyone able to capture it there, and the replay guard never saw it because to Sendoka it was a first use. Conditions/AudienceRestriction must now name the published SP entity ID and is mandatory — an assertion bound to no audience is bound to nobody. SubjectConfirmationData/@Recipient and Response/@Destination must equal the ACS URL when present (the former is inside the signature and optional per spec; the latter rides the unsigned envelope, so it catches misconfiguration rather than attack). Endpoint URLs compare host-case- and trailing-slash-insensitively; entity IDs compare literally, as opaque identifiers.
  • SCIM Groups: /api/scim/v2/Groups (GET/POST + GET/PATCH/PUT/DELETE by id) accepts Okta/Azure AD group push — stored in scim_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 as scim.group.mapped).
  • Mapping policy (src/lib/auth/scim-groups.ts): highest mapped role across a user's groups wins; owners are never touched; owner is 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_role is capped at member/developer/viewer — the Users endpoint also clamps legacy owner directories to member at provision time.