> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication Methods

> After this ships, a user can sign in with a password, a link in their inbox, or their device's biometrics — and none of those paths can be used to reach a member's data.

## Context

This is the Better Auth configuration for the organizational realm. The member realm is configured
separately in `core` (ADR-010), and keeping the two configurations in different modules is what
makes realm separation visible rather than remembered.

The design position worth stating: **we support passwords because B2B customers expect them**, not
because they are good. Everything around them is therefore hardened — breach-list checking, no
composition rules that push users toward `Password1!`, and MFA (FS-IDN-0005) on top for anyone
holding a sensitive permission. Magic link and passkey exist so a tenant that wants to avoid
passwords entirely can.

## Scope *(normative)*

* Password authentication with modern hashing and breach-list checking.
* Magic link over email, single-use and short-lived.
* Passkeys (WebAuthn) as a first-class method.
* Email verification and password reset flows.
* `identity.auth_accounts` linking a user to each method they have enrolled.
* Rate limiting and lockout on every credential path.

## Non-scope *(normative)*

* Social login and enterprise SSO — FS-IDN-0008.
* MFA — FS-IDN-0005. A second factor is a separate concern from a first one.
* Session issuance and lifetime — FS-IDN-0004.
* Member authentication, which lives in `core`.

## Behaviour *(normative)*

1. A user may hold **several methods at once**: a password, a magic link and two passkeys. Removing
   the last method is rejected — it would lock the user out of their own account.
2. Passwords are hashed with a memory-hard algorithm at parameters recorded in the spec and
   reviewed annually. Plain or reversibly encrypted storage is FORBIDDEN, and so is logging a
   password anywhere at any level.
3. **No composition rules.** A minimum length and a breach-list check, nothing else. Rules that
   demand a symbol and a digit produce predictable passwords and a reuse habit; a breach check
   rejects the passwords that are actually compromised.
4. Magic links and reset tokens are single-use, expire in **15 minutes**, are stored hashed, and are
   invalidated when any other one is issued for the same user.
5. Sign-in failures return an **identical response and timing** whether the email exists or not.
   Distinguishing them turns the endpoint into an account-enumeration oracle.
6. Rate limiting is per identifier **and** per IP, with progressive lockout. A lockout notifies the
   user by email, because a lockout they did not cause is the signal that someone is trying.
7. A password change or reset **revokes every other session** of that user and notifies them.
8. Email verification is required before a user carries any role beyond `member`.
9. Passkeys follow WebAuthn; a user may enrol several and name each, because a passkey bound to a
   lost laptop must be removable from a phone.
10. FORBIDDEN: any credential in a log, an event payload, an error message or a URL.

## Data *(normative)*

| Table                          | Key invariants                                                                                                                                                                                                                           |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `identity.auth_accounts`       | FK `user_id`; `method` password\|magic\_link\|passkey\|oauth; `credential` hashed where applicable; `provider`, `provider_account_id` for oauth; `label` for passkeys; `created_at`, `last_used_at`; at least one active method per user |
| `identity.verification_tokens` | `token_hash` unique; `purpose` magic\_link\|email\_verify\|password\_reset; `user_id`, `expires_at`, `consumed_at`; single-use                                                                                                           |

## API *(normative)*

| Endpoint                                                 | Class   | Auth                 | Budget       |
| -------------------------------------------------------- | ------- | -------------------- | ------------ |
| `POST /v1/identity/auth/sign-up`                         | Runtime | public, rate-limited | p95 \<300 ms |
| `POST /v1/identity/auth/sign-in`                         | Runtime | public, rate-limited | p95 \<300 ms |
| `POST /v1/identity/auth/magic-link`                      | Runtime | public, rate-limited | p95 \<300 ms |
| `POST /v1/identity/auth/passkey/{register,authenticate}` | Runtime | public / session     | p95 \<300 ms |
| `POST /v1/identity/auth/password/{reset-request,reset}`  | Runtime | public, rate-limited | p95 \<300 ms |
| `POST /v1/identity/auth/email/verify`                    | Runtime | token                | p95 \<300 ms |

## Events *(normative)*

None on the outbox. Authentication is recorded in `core.audit_log` with a typed actor; a domain
event per sign-in would flood every consumer for no benefit.

## Acceptance criteria *(normative)*

1. Sign-in with an unknown email and with a wrong password are indistinguishable in body, status and
   timing — verified with a timing test, not by inspection.
2. A password on a breach list is rejected at signup and at change.
3. A magic link cannot be used twice, and expires at 15 minutes.
4. Issuing a second magic link invalidates the first.
5. A password reset revokes every other session and sends a notification.
6. Removing the last authentication method is rejected.
7. Progressive lockout triggers on repeated failures and notifies the user.
8. A passkey enrolled on one device can be removed from another.
9. **Negative:** no credential, token or reset link appears in any log, event, error message or URL —
   verified by a scan over a seeded request set.

## Execution

Single slice, synchronous command. Better Auth configured for the organizational realm in
`backend/api`; magic links and notifications are delivered through `messaging` by event, never by
calling a provider from here.

## Open questions

| # | Question                                                                                       | Decides | By              |
| - | ---------------------------------------------------------------------------------------------- | ------- | --------------- |
| 1 | Do passkeys ship in F1a, or is password plus magic link enough to start? (PRD open question 3) | Daniel  | before approval |
| 2 | Minimum password length — 12, or 10 with a mandatory breach check?                             | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
