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

182 lines
10 KiB
Markdown

# 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.