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

# Multi-Factor Authentication

> After this ships, a stolen password is not enough to reach a tenant's customer data.

## Context

Every credential-stuffing incident in this category ends the same way: a reused password, no second
factor, and a full read of a customer database. MFA is the single control with the best ratio of
effort to prevented incidents, and it is table stakes for any B2B platform holding personal data.

Two design positions. **TOTP, not SMS.** SMS is phishable, SIM-swappable and costs money per
message; TOTP works offline, costs nothing and is what every authenticator app already does. And
**recovery codes are the recovery path, not support**. A support-mediated MFA reset is a social
engineering target, and it is the door attackers actually knock on.

The open question the PRD carries — mandatory for everyone or only for sensitive roles — is a real
trade. Mandatory everywhere is safer and adds friction for a marketing assistant who can only read
a dashboard; mandatory for sensitive permissions targets the risk precisely and needs the
permission model to be right, which FS-IDN-0002 makes it.

## Scope *(normative)*

* TOTP enrolment, verification and removal.
* Single-use recovery codes, hashed, generated as a set.
* MFA challenge during sign-in and on re-authentication.
* Enforcement policy: mandatory for roles holding sensitive permissions.
* Organization-level policy allowing a tenant to require MFA for everyone.

## Non-scope *(normative)*

* **SMS as a factor.** Explicitly not offered — phishable, SIM-swappable, and it costs per message
  for weaker security than a free app.
* Hardware keys as a distinct factor. A passkey (FS-IDN-0003) already covers that ground.
* MFA for members. Members authenticate by OTP or delegated token, and both are already single-use.
* Support-mediated MFA reset — see behaviour rule 6.

## Behaviour *(normative)*

1. TOTP follows the standard 30-second window with a ±1 window tolerance for clock drift. A code is
   **single-use**: replaying it inside its window is rejected.
2. Enrolment is **not complete until a code is verified**. An enrolment that stores a secret without
   proving the user can generate from it locks people out.
3. Recovery codes are generated as a set of ten at enrolment, shown **exactly once**, stored hashed,
   and each is single-use. Using one notifies the user.
4. Fewer than three unused recovery codes remaining warns the user and offers regeneration.
   Regenerating invalidates the whole previous set.
5. Disabling MFA requires re-authentication **and** a current second factor, and notifies the user.
6. **Support cannot reset MFA.** A user who loses both their authenticator and their recovery codes
   is recovered by an organization owner, and if the last owner is the one locked out, by a
   documented manual process with identity verification that is deliberately slow. Making this easy
   is making the attack easy.
7. A role holding any `is_sensitive` permission **requires MFA**. Assigning such a role to a user
   without MFA grants it but blocks its use until they enrol, and tells them why.
8. A tenant may set an organization-wide requirement, which applies to every member of that
   organization on their next sign-in.
9. The MFA challenge is rate-limited and locks out progressively, like any credential path.

## Data *(normative)*

| Table                         | Key invariants                                                                                                                              |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `identity.mfa_factors`        | FK `user_id`; `type` totp; `secret` encrypted at rest; `verified_at` NOT NULL for an active factor; `last_used_at`; unique per (user, type) |
| `identity.mfa_recovery_codes` | FK `user_id`; `code_hash`; `consumed_at` nullable; generated and invalidated as a set                                                       |

## API *(normative)*

| Endpoint                                            | Class      | Permission                      | Budget       |
| --------------------------------------------------- | ---------- | ------------------------------- | ------------ |
| `POST /v1/identity/mfa/enroll`                      | Management | session-owner, re-auth required | p95 \<300 ms |
| `POST /v1/identity/mfa/verify`                      | Runtime    | pending challenge               | p95 \<300 ms |
| `POST /v1/identity/mfa/recovery-codes`              | Management | session-owner, re-auth required | p95 \<300 ms |
| `POST /v1/identity/mfa/disable`                     | Management | session-owner, re-auth + factor | p95 \<300 ms |
| `PUT /v1/identity/organizations/current/mfa-policy` | Management | `identity.organizations.update` | p95 \<1 s    |

## Events *(normative)*

`identity.mfa.enabled` and `identity.mfa.disabled`. Both also produce a notification to the user
through `messaging`, because an MFA change the user did not make is the strongest possible signal
that something is wrong.

## Acceptance criteria *(normative)*

1. A TOTP code cannot be replayed within its window.
2. Clock drift of ±30 seconds still verifies; ±90 does not.
3. Enrolment without a verified code leaves no active factor.
4. A recovery code works once and notifies the user; the same code fails afterwards.
5. Regenerating recovery codes invalidates every previous one.
6. Assigning a role with a sensitive permission to a user without MFA grants it and blocks its use
   until enrolment, with a typed reason.
7. An organization-wide requirement applies on the next sign-in for every member.
8. Disabling MFA without re-authentication and a current factor is rejected.
9. **Negative:** no support endpoint, admin action or Ops tool can reset a user's MFA.

## Execution

Single slice, synchronous command. TOTP through Better Auth's plugin; secrets encrypted at column
level. Notifications go through `messaging` by event.

## Open questions

| # | Question                                                                                         | Decides | By                       |
| - | ------------------------------------------------------------------------------------------------ | ------- | ------------------------ |
| 1 | Mandatory for every user, or only for roles holding sensitive permissions? (PRD open question 1) | Daniel  | before approval          |
| 2 | The last-owner lockout process — who verifies identity, and how?                                 | Daniel  | before commercial launch |

## Changelog

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

## Delivery record

*Not implemented yet.*
