# 0038: Email verification and password recovery, with a trusted-bootstrap exception - Status: Accepted - Date: 2026-08-25 - Extends: [0010](0010-core-identity-and-better-auth-adoption.md), [0011](0011-identity-http-transport-and-public-enrollment.md), [0034](0034-host-owned-browser-authentication.md), [0035](0035-initial-platform-owner-bootstrap.md), [0036](0036-managed-device-identity-and-pairing.md), [0037](0037-host-provided-outbound-email.md) ## Context Ekklesia can authenticate a person, hold a session, pair a device, and — since [ADR 0037](0037-host-provided-outbound-email.md) — send a transactional email. What it cannot do is either half of the ordinary human identity lifecycle: it has no way to establish that an address belongs to the person who claimed it, and no way for someone who has forgotten their password to get back in. The Better Auth hooks for both have existed and been deliberately unimplemented since [ADR 0011](0011-identity-http-transport-and-public-enrollment.md); Pass 1 built the transport they were waiting for. Adding them is not simply "turn the feature on", because two things about this platform are unusual. The **initial platform owner** is created by an operator-run command ([ADR 0035](0035-initial-platform-owner-bootstrap.md)) on a freshly provisioned server, at a moment when SMTP may not be configured, may not be reachable, and may not yet have a domain to send from. A verification requirement applied naively would produce a correctly installed platform whose only administrator can never sign in to configure the very mail service the requirement depends on. And **password recovery is an anonymous, unauthenticated mutation** that sends mail to an address the caller chose. That is the classic account-enumeration surface, and it is also the classic way to turn a platform into someone else's mail cannon. ## Decision ### Better Auth owns the mechanics; Ekklesia owns the experience ```text host-owned browser screens ← Ekklesia ↓ Ekklesia auth boundary ← Ekklesia ↓ Better Auth ← tokens, hashing, expiry, consumption, session policy ↓ "deliver this" identity notification adapter ← Ekklesia: recipient, subject, bodies ↓ MailProvider seam → SMTP ← ADR 0037 ``` Ekklesia implements **no** token table, token hashing, token expiry, or token consumption. Those exist and are correct in the pinned release, and a second implementation would be a second thing to get wrong. A structural test asserts that no Ekklesia schema gains a reset or verification token column and that identity code never hashes or compares one. ### A verified address is a precondition for holding a session `emailAndPassword.requireEmailVerification` is enabled. In the pinned release this is enforced *after* the password is validated and *before* any session is created: an unverified sign-in fails with `403 EMAIL_NOT_VERIFIED` and **no cookie is set**. That single fact is what keeps the principal model unchanged. There is no "unverified principal", because there is no session to attach one to — a correct password on an unverified address produces exactly the same authority as no password at all. `/api/v1/principal` continues to describe only `anonymous`, an authenticated human, and a managed device, and the Dashboard shell continues to render authenticated content only after the principal endpoint says so. Nothing was added to the public principal union, and nothing needed to be. ### The initial platform owner is created already verified `createInitialPlatformOwner` marks the address verified — writing Better Auth's own `emailVerified` column, so the resulting row is indistinguishable from one that verified normally — before it grants ownership. If that write fails, the grant and the one-time sentinel roll back together and the command remains available. The exception's authority is the same authority that already makes the command safe: **possession of the database, not possession of the mailbox.** It is not a weaker policy, it is a different and stronger proof, available exactly once per installation. `pnpm identity:create-initial-owner` gains no mail prerequisite of any kind. An adopted identity is marked verified too. The alternative — bootstrap succeeding and producing an owner who cannot sign in — is the failure this whole exception exists to prevent. Migration `0007_verified_bootstrap_owners` marks currently-granted platform owners of *existing* installations verified, so enabling this policy cannot lock an operator out of a platform they already run. It is deliberately narrow: it touches only identities the authorization tables already record as owners, and no other account. ### Links are built from configuration, never from a request Better Auth hands its callbacks a prebuilt URL embedding whatever `callbackURL` or `redirectTo` the requesting client supplied, and validates those against its trusted origins. That validation is sound. It is nonetheless not what Ekklesia relies on: the notification adapter takes **only the token** and rebuilds the link from validated configuration. The result is that no request parameter can influence where a recipient lands, independently of whether any origin check behaves as expected. An emailed link is the one artifact a person clicks without looking. The destination is a new validated `IdentityConfig.dashboardOrigin` — the **Dashboard** browser origin, which defaults to the identity base URL (the ordinary same-origin shape) and may be configured separately (`EKKLESIA_DASHBOARD_ORIGIN`) for a deployment that splits its API from its Dashboard. It is validated to be a canonical origin, https and non-loopback in production, and a Better Auth trusted origin — so a misconfiguration fails at startup instead of producing a link every recipient is refused at. It is explicitly **not** the public church website origin: this is the authenticated Dashboard host and nothing else. ### Following a link never mints a session `emailVerification.autoSignInAfterVerification` is `false`. Verification links travel through mail infrastructure and are routinely dereferenced by things that are not the recipient — link scanners, prefetchers, corporate mail gateways. Turning that GET into a logged-in session would hand a session to whatever fetched the URL. Verification flips the flag and the person signs in afterwards. This also makes replay harmless, which matters: the verification token is a signed JWT rather than a stored row, so it is **not single-use** — it is valid until it expires (one hour). Replaying it on an already-verified address is a no-op that mints nothing. The reset token *is* a stored row and *is* consumed, so a captured reset link cannot be replayed. ### Password reset revokes that human's sessions `emailAndPassword.revokeSessionsOnPasswordReset` is enabled. Recovery is what someone uses when they believe their credential is compromised; leaving the attacker's session alive would make it cosmetic. This is Better Auth's own supported option, not a Core reach into the session table. It deletes *all* of that person's sessions, including the browser performing the reset — so the completion screen returns to sign-in rather than pretending to be authenticated. **Managed device sessions are unaffected**: a device is a separate credential digest keyed by device ([ADR 0036](0036-managed-device-identity-and-pairing.md)), holds no email and no password, and has no row in the human session table. ### Recovery is enumeration-safe, including when mail is broken The user-facing answer is identical for an address with an account, an address without one, an already-verified address, and an address whose message could not be delivered. Making that true required one thing beyond the library's own behaviour. Better Auth's `/send-verification-email` rethrows whatever the delivery callback throws, which would make "this address has an unverified account" observable the moment SMTP broke. **The notification adapter therefore never throws.** A failure is reported out of band with a sanitized code and no address, token, or link, where no HTTP response shape depends on it. ### Sending is still direct, non-retrying, and rate-limited per process [ADR 0037](0037-host-provided-outbound-email.md)'s discipline is unchanged: one bounded attempt, no application-level retry. A timeout after SMTP DATA is genuinely ambiguous, and retrying it sends the same reset link twice. Better Auth's own limiter applies **3 requests per 60 seconds per IP** to password-reset requests and verification resends, and 3 per 10 seconds to sign-in. That limiter is in-memory and per process. It is a real abuse control for a single deployment and is deliberately **not** claimed to be a distributed one; a multi-process deployment gets that budget per process. Resending is also reachable by simply attempting to sign in (`emailVerification.sendOnSignIn`), which costs nothing to secure because it requires the correct password. ### Mail-less installations still run An installation with no SMTP configured starts, serves, and signs its bootstrap owner in. It configures the identical verification policy and simply cannot deliver; every attempt reports `MAIL_NOT_CONFIGURED` through the reporter. Absent mail does **not** relax `requireEmailVerification` — which is precisely why the bootstrap exception exists. The operator requirement is therefore: **normal production use of verification and recovery requires configured outbound email; initial platform-owner bootstrap does not.** ### Public signup remains disabled Email verification is not a licence to self-register. `POST /api/auth/sign-up/email` stays refused, production still cannot enable it at all, and the browser shell offers no registration control. A regression test asserts each. Future human accounts will arrive through deliberate administration or invitation, which this decision does not design. ## Consequences - A human account's lifecycle is complete: it exists, verifies, signs in, recovers, and signs out. - The principal union, the device model, and the module-facing surface are all unchanged. - Ekklesia Lite is unaffected: it has no human authentication, so it acquires no SMTP requirement. - Verification and recovery are now the second and third things that will break if outbound email is misconfigured. `pnpm doctor`'s `outbound-email.configuration` check is the place an operator finds that out. - Deferred deliberately: invitations, user administration, email-change verification, multiple addresses, MFA, OAuth/SSO, a durable mail outbox, and distributed rate limiting.