Status: accepted · Date: 2026-10-31 · Related: ADR-0015 (care roles on Better Auth), ADR-0028 (clinical privilege matrix), ADR-0002 (leaf modules), ADR-0009 (RLS)
Enforcement: packages/auth/src/session-assurance.test.ts (ASSURANCE: …, P1-A: …, P1-B: …), packages/db/src/sync-trigger.test.ts (PRIVILEGES: session_assurance … refuse tenant INSERT), packages/email/src/templates.test.ts (EMAIL: the OTP email renders in de, en and pt and contains no clinical content).
Spec Gate finding 2 called out that we had no way to assert "the current session was proven with a factor at least this strong". A clinical boundary that gates by user.twoFactorEnabled is lying: the flag only records that TOTP exists for the user — not that this session verified it. The same hole lets a WebAuthn plugin accept an assertion without User Verification (UV = 0), i.e. presence alone. This ADR records how enrolment, sign-in and local unlock close that hole.
Decision
Server-written session assurance
Every Session carries a session_assurance row the server writes at the moment the Session is established (packages/db/src/schema/care.ts). The level is one of:
strong— a passkey ceremony with User Verification (the patched@better-auth/passkeyplugin, ticket 02 —requireUserVerification: trueat both call sites), or password plus TOTP, or password plus a backup code.device— a basic session lifted bydevices.attestfrom an active device of this User (ticket 18 adds the write path).basic— password alone, email OTP, or the invitation email verification. (The classifier labels/verify-emailwith methodemail_otp: both ceremonies prove address control and the clinical boundary at ticket 17 reads onlylevel. If a later ticket needs to distinguish link-click from code entry, split the method enum there.)
Classification is by Better Auth endpoint path only (classifyAssurancePath in packages/auth/src/session-assurance.ts); user.twoFactorEnabled and a stored passkey never count. Only the server writes the row, under the auth.assurance system reason: app_tenant has SELECT and no INSERT, pinned by the RLS matrix. Unknown session-creating paths (admin impersonation, oauth flows) write no row; the boundary (ticket 17) must treat absence as less than strong.
Enrolment refusal under impersonation (ticket 14b)
Credential enrolment (/passkey/generate-register-options, /passkey/verify-registration, /two-factor/enable) is refused when the current session is impersonated (session.session.impersonatedBy set). An admin impersonating a target would otherwise register their own authenticator against the target and sign in later as the target without impersonatedBy — a persistent takeover with no audit trail of impersonation. The refusal fires in hooks.before (packages/auth/src/index.ts), so no credential is ever persisted and no new target session is ever created.
Defence-in-depth: /passkey/verify-registration with createSession: true only yields a strong session when the registering session was itself strong and not impersonated. If the before-hook refusal is ever bypassed, the new session still never claims strong assurance. First-time passkey enrolment from a basic (password or email-OTP) session therefore leaves the enrolling session unchanged; the user signs in via /passkey/verify-authentication to obtain a strong session with their new passkey.
Email-OTP routes we do not expose
The Better Auth emailOTP plugin ships eight HTTP routes, several of which increment a per-code attempt counter on failure. Ticket 14's keepsFailureWrites preserves the counter past the auth transaction only on /sign-in/email-otp and /email-otp/verify-email. Any other verify-style route would roll its counter back on 4xx, letting a distributed attacker evade the 3-attempt budget. The product uses only sign-in-OTP, verify-email, and send-verification-otp; the rest are disabled in hooks.before with HTTP 404 (packages/auth/src/index.ts, DISABLED_EMAIL_OTP_PATHS):
/email-otp/check-verification-otp/email-otp/request-password-reset/email-otp/reset-password/email-otp/request-email-change/email-otp/change-email/forget-password/email-otp
Disabling at the door is simpler and tighter than extending keepsFailureWrites: the plugin never runs, no counter is ever written, no code is ever fed to its verifier.
Enrolment
An invitation link → email OTP proves the invited address (basic session). Then a passkey is created with User Verification (step up to strong). Then devices.register activates the device (keys delivered). If no platform authenticator is available the device registers pending_activation, receives no keys, and is activated by a 6-digit code the PDL issues (D-Team screen). Later sessions on an activated device step up from basic to device by key attestation (ticket 18 lifts this; this ADR only records the shape).
Local unlock
PIN on the device unlocks the local key; biometrics (Face ID / Fingerprint on native) is an unlock accelerator, not a factor on its own. Five wrong PIN attempts lock the device for 15 minutes; ten destroy the local key. A PDL-issued unlock code recovers from a hard lock. The server does not see the PIN; its role is to deliver a new device_code on PDL request.
Email OTP template
Email OTP uses the Better Auth emailOTP plugin with the account email transport. One template per language (de/en/pt), DE formal "Sie" (clinical context), no clinical content ever — no wound, patient, dressing or any care terminology appears in subject, body or preheader. Codes are stored hashed (storeOTP: "hashed"), expire after 5 minutes, and burn after 3 bad attempts (allowedAttempts: 3). The per-IP rate limit is max: 3 / 60 s on send and max: 5 / 60 s on verify.
Consequences
- The clinical authorization boundary (ticket 17) has one authoritative read:
getSessionAssurance(db, sessionId). A missing row is weaker than any explicit level. - The passkey patch is load-bearing. If a later
pnpm installloses the patch, the UV refusal test insession-assurance.test.tsfails; ticket 02 also pins the source check (MANIFEST: the installed @better-auth/passkey source contains no "requireUserVerification: false"). keepsFailureWritescovers only the two retained email-OTP verify paths (/sign-in/email-otp,/email-otp/verify-email): a wrong code must commit its attempt counter past the auth transaction rollback, or a brute-force walk through the one-million code space is free. Every other email-OTP verify path is 404'd by thehooks.beforeguard above (ticket 14b), so no counter is written on those paths at all.- Impersonated enrolment is a 403 with code
IMPERSONATED_SESSION_CANNOT_ENROL; the mobile and web clients surface a translated "stop impersonating before enrolling" message. - Session assurance is the mechanism; the boundary's
requireStrongAuthpolicy (ticket 17) is the thing that reads it. Policy may evolve without changing this ADR.
Alternatives rejected
- Treat
user.twoFactorEnabledas the strong-auth check. It records existence of TOTP, not that this session verified it — a user with 2FA enabled who signs in via a passkey without UV still passes. Rejected: fails Spec Gate finding 2 head-on. - Compute assurance on the client. The client can lie; the server has to be the one writing. The clinical boundary runs server-side anyway.
- Keep the attempt counter in a sidecar table. The email-otp plugin encodes it in the stored OTP value and would need forking to use a sidecar. Extending
keepsFailureWritesis one line and keeps us on the upstream plugin. - Overloaded "device" level before ticket 18. The
deviceenum value exists today but the writer is deferred to ticket 18; a half-wired level would blur the boundary's contract. The classifier returns onlybasicorstronguntil then.