Files
Ekklesia/docs/architecture/decisions/0038-email-verification-and-password-recovery.md
T
ekklesiaandClaude Opus 5 ea28f8aa2e
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s
feat: verify email addresses and recover forgotten passwords
Ekklesia could authenticate a person and, since Pass 1, send a
transactional email — but it had no way to establish that an address
belonged to whoever claimed it, and no way back in for someone who
forgot their password.

A verified address is now a precondition for holding a session. Better
Auth enforces that after the password check and before any session
exists, so an unverified sign-in returns 403 with no cookie. That is why
no "unverified principal" was needed: a correct password on an
unverified address carries exactly the authority of no password, and
/api/v1/principal is unchanged.

Better Auth keeps every token. Ekklesia adds no token table, no hashing,
and no expiry clock. It supplies a recipient, a subject, and two bodies,
and the mail provider supplies everything about SMTP.

Verification and reset links are rebuilt from a validated Dashboard
origin, taking only the token — never the URL Better Auth prebuilds from
a caller-supplied callbackURL — so no request can steer where a
recipient lands. Following a verification link mints no session, because
mail infrastructure dereferences links. Reset revokes that person's
other sessions and leaves managed devices alone.

Recovery answers identically for an address with an account, without
one, already verified, or undeliverable — including when SMTP is broken,
which is why the delivery adapter never throws.

The initial platform owner is the one exception: created already
verified by an operator command with no mail prerequisite, so a fresh
server can be signed in to before a relay exists. Migration 0007 extends
that to owners of installations that already exist. Public signup stays
disabled; verification is not a licence to self-register.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 07:21:17 -05:00

10 KiB

0038: Email verification and password recovery, with a trusted-bootstrap exception

Context

Ekklesia can authenticate a person, hold a session, pair a device, and — since ADR 0037 — 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; 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) 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

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.

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.

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), 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'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.