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).
Passwordless (SSO / OAuth) accounts also prove their inbox before the factor turns
on. The panel shows a second field and an Email me a code button, which calls
POST /api/internal/user/deletion-code { "for": "two_factor_setup" }; verify then takes
{ code, email_code }. Without the emailed code it answers 403 EMAIL_CODE_REQUIRED
(EMAIL_CODE_INVALID for a wrong, expired or used-up one); the TOTP is checked first, so
a wrong authenticator code spends no guess on the emailed one, and the emailed code is
consumed once the factor is on. The reason: for an account without a password, a TOTP
alone confirms org deletion and owner grants (below), so a stolen session must not be
able to enrol its own authenticator. Accounts with a password enrol exactly as before.
src/lib/auth/enroll-stepup.ts. Under an org require_2fa mandate the proxy admits the
code request (/api/internal/user/deletion-code) alongside setup and verify, so a
passwordless member can still enrol; the routes that code could otherwise confirm stay
refused until they do.
Disable flow
- Click Disable 2FA → panel asks for the password and a code from the authenticator app (or a backup code). Passwordless (SSO / OAuth) accounts are asked for the code only.
POST /api/internal/two-factor/disable { password, totp }(passwordless:{ totp }) — server bcrypt-compares the password, then checkstotpagainst the enabled authenticator (a live code or an unused backup code), and deletes the row on success. A correct password with no code answers401 TOTP_REQUIRED; a wrong code401 TOTP_INVALID. Both arms share thetwo_factor.disablebucket (5 per 15 minutes).- After the row is deleted the route signs out the user's other sessions (their
user_sessionsrows are revoked; the caller's own session stays, sosession_versionis not bumped), revokes every OAuth / MCP / CLI grant (revokeAllOAuthGrantsForUser, as "Sign out everywhere" does), and emails a "Two-factor authentication was turned off" notice to the account address. A failed email is logged (two_factor.disable.notify_failed) and never fails the request. A failed revoke is logged too, and the200then carries{ enabled: false, warning }and the panel shows the warning: 2FA is off either way, and the caller can finish the job with Sign out everywhere.
The code is required because removing the authenticator also removes the TOTP step-up in front of account and org deletion (below): with a password alone, someone holding the session and the password could disable 2FA and then erase the account without ever touching the second factor. A half-enrolled row (setup started, never verified) is not a factor, so it still needs only the password.
Account deletion
DELETE /api/internal/user (Settings → Security → Account, and /no-org) asks for
proof the session alone does not carry:
| Account | Body must include |
|---|---|
| Password, no 2FA | password |
| Password + 2FA | password and totp (a live code or an unused backup code) |
| Passwordless (SSO / OAuth) + 2FA | totp |
| Passwordless, no 2FA | code, emailed by POST /api/internal/user/deletion-code |
Refusals are 403s naming what is missing: PASSWORD_REQUIRED, PASSWORD_INVALID, TOTP_REQUIRED,
TOTP_INVALID, EMAIL_CODE_REQUIRED, EMAIL_CODE_INVALID. Retyping the account's
email (confirm_email) is no longer accepted: the address is visible to anyone holding
the session. The emailed code reuses the login_codes rows (10-minute TTL, five
guesses, a new one supersedes the last); the send route is limited to 3 per 15 minutes
and answers 409 EMAIL_CODE_NOT_APPLICABLE for an account that has a password or an
authenticator. All proofs share the user.erase bucket (5 per 15 minutes).
DELETE /api/internal/org?confirm=<slug> (Settings → Danger zone) asks for the same
proof in its JSON body, with the same refusal codes, behind its
own org.delete bucket (5 per 15 minutes). The slug is printed on the page, so on its
own it never stopped a stolen owner session from cascading every org it owns. A
passwordless owner requests the code with POST /api/internal/user/deletion-code { "for": "org" }; only the mail's wording differs, and either code confirms either
route. Both routes share reauthRefusal (src/lib/auth/reauth-gate.ts).
Codes are single-use, and one budget covers every step-up. A backup code that
confirms any step-up (2FA disable, account delete, org delete, owner grant) is removed
from the account by the same atomic array_remove sign-in uses, and a TOTP code spends
its time step, so neither confirms a second action; an action that then refuses for an
unrelated reason (SOLE_OWNER) has still spent the code. On top of each route's own
bucket, every code checked spends the per-user step_up_factor bucket
(src/lib/auth/factor-attempts.ts, 5 per 15 minutes, strict), so rotating routes buys
no extra guesses. It is charged only when a factor is enrolled and a code was sent,
after the password check; when empty, the erasure and owner-grant routes answer
429 RATE_LIMITED and the 2FA disable answers 429. It is separate from login_2fa,
so a session holder spending it cannot lock the member out of signing in.
Granting the owner role
An owner can delete the org, and the delete re-authenticates only its own caller. So
any action that grants owner asks the granting session for the same proof first:
POST /api/internal/team/invite with role: "owner" (a resend included, since it
mints a fresh link) and PATCH /api/internal/team/members promoting someone to
owner. Same body fields, same refusal codes, behind its own team.owner_grant bucket
(5 per 15 minutes); ownerGrantStepUp in src/lib/auth/owner-grant-stepup.ts.
Without it, a stolen owner session could invite an attacker as owner, who accepted
and deleted the org with their own password. Non-owner invites and role changes,
demotions, and owner-to-owner no-ops ask for nothing. A passwordless owner requests
the code with POST /api/internal/user/deletion-code { "for": "owner_grant" }.
Owner invites issued before this proof existed were never verified, so accept refuses
them. Every invite token minted since carries an iv2. prefix (. is outside the
nanoid alphabet); an owner invite whose token lacks it answers
410 INVITE_REISSUE_REQUIRED, writes nothing, and the invite page tells the invitee
to ask an owner to resend. A resend runs the proof and rotates the token. The check
keys on the code that minted the token, not on a date, so it holds however late the
fix is deployed. Non-owner invites are unaffected.
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) for a time step not already spent → success. A code accepted at any door is refused for the rest of its window (
src/lib/auth/totp-replay.ts). - 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, spent once by the same replay guard, 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/sso-completedrops the org-switcher cookie for that org when the member continues, so they land 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.
No self-service 2FA recovery within 7 days of a password reset.
/api/auth/recover-2fa asks for a mailed link and the password, and the
password is resettable from the same inbox — so mailbox access alone used to
chain forgot-password → reset → forgot-2fa → recover-2fa and delete the second
factor, org 2FA mandate or not. After a correct password the route now looks for
a password_resets row redeemed in the last 7 days (used_at is stamped only
by a real reset) and answers 403 { code: "RECENT_PASSWORD_RESET" }, pointing
the user at a backup code or support. Nothing is burned or removed on a refusal.
Rows superseded before this change were stamped used_at too, so for the first
7 days after deploy a user who merely re-requested a reset link may be refused
as well — fail-closed, and self-clearing.
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
after the emailed login code is verified and 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.
authorize() used to answer SSO_REQUIRED before the code check, so a
credentials POST with a garbage code read SSO_REQUIRED vs INVALID_CODE as
"this address is registered and is a non-owner member of an enforcing org"
(SEC-24). The membership check now runs only past the code gate, which a
missing account can never pass. A domain-only early arm was deliberately not
added there: login-code already gives that answer, and repeating it in
authorize() would refuse the break-glass callers (owners, non-members at the
domain) that ssoEnforcedForEmail exempts.
Enabling enforcement bumps session_version for all members except the
acting owner — existing password/OAuth sessions end immediately; members
re-auth through the IdP. The same members' OAuth grants (MCP / CLI browser
logins: refresh tokens, issued access keys, unredeemed codes) are revoked in
every org with revokeAllOAuthGrantsForUser, so a grant consented from a
password session cannot keep minting keys after the switch. Members reconnect
their clients from an SSO session. The refresh grant itself does not re-check
enforce_sso: the browser path checks it only at sign-in, and a refresh token
records no trace of how its consent was authenticated, so a re-check would cut
off grants members legitimately consented from a SAML session.
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. A member who joins a mandated org mid-session (invite accept, SAML JIT, SCIM provisioning) is caught without a new login: the callback re-derives the flag on every session-cache miss and on every explicit refresh. The confinement is read off the session cookie, so it lands when the cookie is next re-minted by a/api/auth/sessioncall. The invite page forces that refresh right after accepting, so an invite lands confined at once. Other joins land at the next session refresh after the ~30 s cache expires (the dashboard refetches on window focus). 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. OAuth grants (MCP / CLI browser logins) are bound too. Turning the mandate ON revokes this org's grants of the same members whose sessions it ends (revokeOrgOAuthGrantsForUser; grants held for other orgs are left to their own refresh check; best-effort, a failure logged asoffboard.oauth_revoke_failed). After that,POST /api/oauth/token's refresh grant answersinvalid_grantand revokes the grant family whenever the member owes enrollment (oauthGrantOwes2faEnrollment, which isorgMandates2faForUserand "no enabled secret"). That covers a member who joins a mandated org while holding a grant, and a revoke that failed. A lookup failure answers500 server_errorwithout spending the token. A grant held for an org with an active SSO connection is waived: it may have been consented from that org's exempt SAML session, and the refresh token cannot tell.
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.
Creating a connection
POST /api/internal/sso (owner-only) binds the connection to email_domain, which
must be a domain this org holds in verified status (409 otherwise). The org's
sandbox sender ({slug}.sandbox.sendoka.com, or any platform-managed row) does not
count: every signup gets it verified with no proof, and entity_id is unique across
all orgs, so it let any new account register another company's IdP entity ID and
lock the real owner out of connecting SSO. That answers 409 naming the domain as
operated by Sendoka.
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. - The ACS bounds what it will read before verifying anything. The POST is
unauthenticated and is decoded and scanned for the issuer before any
signature check, so a
SAMLResponseover 256 KB of base64 (about 192 KB of XML — far past a real response, group lists included) answers413. Size alone does not bound the parse, because the XML parser is quadratic in attributes and namespace declarations, so one linear pass first refuses a document with more than 10,000 elements, 10,000 attributes, 64 attributes on one element, more than 128xmlnsdeclarations in scope at one element or 128 distinct namespace bindings in the document, or any DOCTYPE —413 SAML_RESPONSE_TOO_COMPLEX. Identical declarations repeated on sibling elements (Okta re-declaresxmlns:xs/xmlns:xsion every group value) do not add up. One client IP gets 120 POSTs a minute before429withRetry-After: 60. If Redis is down the limiter falls back to a per-instance memory window rather than failing open, so an outage neither blocks SSO sign-in nor unbounds the parse. The issuer peek itself is linear: its old pattern was quadratic, and a 4 MB body could hold a function for its whole budget. /sso-completeasks before it signs anyone in (SEC-26). The ACS accepts only unsolicited (IdP-initiated) responses, so nothing ties a response to the browser POSTing it — anyone can auto-submit their own signed response from a victim's tab (login CSRF). Unless the browser already holds a session for the very account the token is for, the page names that account and the SSO org and waits for Continue, which sets the org-switcher cookie and then callssignIn(); Cancel clears the token cookie and the user row's token. The page cannot be framed (frame-ancestors 'none'). An already-signed-in member is also told which account the login replaces. SP-initiated login with a cookie-boundInResponseTois not built.- SCIM Users:
/api/scim/v2/Users(GET/POST + GET/PATCH/PUT/DELETE by id; helpers insrc/lib/auth/scim-users.ts).activeis read as a boolean or as the string"true"/"false"in any case — Entra ID sends"False"— and any other value is400 invalidValue. Creating, attaching or reactivating a user needs the org to prove the email's domain: a verified domain equal to it or a verified parent of it, held inverifiedstatus at the time of the request. An SSO connection bound to the domain is not proof on its own: once the domain row is deleted or drops out ofverified, the directory can no longer attach, reactivate or rename users there, whatever the connection's status. A verified subdomain does not prove its parent — an org sending frommail.acme.comverifiesacme.comtoo before pushing@acme.comusers (403 mutabilityotherwise). The same proof gates renames:users.nameis global, so PATCH/PUT write a name only for a user at a domain the org proves (every user the directory provisioned qualifies), and otherwise ignore the name and apply the rest of the update (SEC-19) — an invited outsider keeps their name in every org. Deactivation (active:falseon PATCH, PUT or POST) removes the org membership and revokes the user's OAuth grants for the org, but the user stays addressable by id for the directory that deactivated them: GET answersactive:falseand a lateractive:truere-creates the membership (domain proof and seat cap re-checked, group role re-applied). There is no mapping table, so this is read from the directory's own audit rows; a DELETE or a manual removal from the team page ends it.GET /Users?filter=supports oneeqonuserName,emails,emails.value,emails[value eq …],idorexternalId(Sendoka stores no externalId, so that one always lists nothing); anything else is400 invalidFilter. The list shows current members only. - 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.