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 locally in the browser with the
qrcodepackage from theotpauth://URI — the seed never leaves the app) 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.
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 againstusers.saml_login_token— the prefix is exactly as unforgeable as the token — and records it astoken.samlOrgId;- the jwt callback derives
mustEnroll2fa/mustVerify2fafor SAML sessions like any other, andsamlExemptsTwoFactor(src/lib/auth/saml-scope.ts) waives them insrc/proxy.tsand in the session mirror while the active org issamlOrgId. 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_verificationsrow 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'smaxAge. 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 —attemptsis incremented by the database with the ceiling in theWHERE— 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 aCASE, so a correct code never spends an attempt.login_codesuses 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.phoneis 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 sharedlogin/login_emailbuckets (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/retentiondeletesphone_verificationsrows 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 onemail_verifiedalone would have locked out most of the existing user base:sendVerificationEmailbuilt 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 setsignup_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_verificationstoken, which is the row's own 48-char primary key and never a body-supplied value — same reasoning assendoka_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 theWHERE,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. SoverifyEmailCoderefuses link rows andconsumeVerificationTokenrefuses 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 ofphone-challengedown 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 inauthorize()'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, notlogin. Sharingauthorize()'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 looselogin_emailbucket still counts both doors. - Codes are reaped.
/api/cron/retentiondeletesemail_verificationsrows older than 7 days — the same erasure argument asphone_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
minted — validateApiKey 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.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.
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 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).require_2fa— org 2FA mandate. Turning it ON bumpssession_versionfor 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 setstoken.mustEnroll2faandsrc/proxy.tsconfines 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 asorg.security_policy_updatedwith 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/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. - 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/AudienceRestrictionmust now name the published SP entity ID and is mandatory — an assertion bound to no audience is bound to nobody.SubjectConfirmationData/@RecipientandResponse/@Destinationmust 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 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.