# Multi-Factor Authentication

> Second factors in nauthera-server: TOTP, passkeys, emailed codes and recovery codes; the deployment, organization and client policy, network zones and lockout.

nauthera-server offers three second-factor methods: **TOTP** (RFC 6238), **passkeys**
(WebAuthn) and, where a policy names `email`, a one-time **code emailed** to the user's
verified address. Single-use **recovery codes** back all three. It works end to end: the
hosted UI enrols users, challenges them at sign-in with every method they hold, and
accepts recovery codes. Which methods a sign-in may use, whether a factor is required,
and when a user who holds one is spared the challenge is a **policy** that the
deployment, the organization and the client narrow together.

For what the user sees, see [Second factor](https://www.nauthera.io/docs/v1/sign-in-flows/second-factor) and
[Passkeys](https://www.nauthera.io/docs/v1/sign-in-flows/passkeys).

## How it works

1. **Enrolment.** A user enrols on `/mfa/setup`. For TOTP the server generates a seed and
   returns an `otpauth://` URI (SHA-1, 6 digits, 30-second period) for the user's
   authenticator app, and the user confirms with a first code. For a passkey the browser
   runs the registration ceremony. For an [emailed code](#emailed-code) the server mails a
   code to the verified address, and the user confirms with it. The account's first
   factor comes with a set of recovery codes, shown once, which the user acknowledges. A
   factor added beside another mints none: the codes back the account, not the factor.
2. **Sign-in.** When a user who holds a factor signs in, the server answers with a
   second-factor step instead of a session. A short-lived pending-authentication cookie
   connects the two steps. The user answers with a TOTP code, an emailed code or a passkey
   on `/mfa`, or a recovery code on `/mfa/recovery`. The account's
   [status](https://www.nauthera.io/docs/v1/login-experience#account-status) is checked again at this step, so
   an account suspended while the code was being entered gets no session.
3. **Recovery.** Each recovery code works once. Regenerating codes replaces the whole
   set.

A session that passed a second factor carries `amr` with the first factor, the method
(`otp` or `swk`) and `mfa`, and `acr` for the method's assurance: `aal2` after TOTP or a
passkey, `aal1` after an emailed code, since NIST SP 800-63B-4 does not accept email as an
out-of-band channel.

## Emailed code

An operator opts into the emailed code by naming `email` in a policy's methods. The
account needs a verified address, and the server sends the code through the
organization's [email relay](https://www.nauthera.io/docs/v1/email#relays), its own or the deployment's. A code
is six digits, works once and for ten minutes, and each new one replaces the last; an
account is sent one every 30 seconds and five every 30 minutes at most. For what the user
sees, see [Second factor](https://www.nauthera.io/docs/v1/sign-in-flows/second-factor#set-up-a-second-factor).

## The policy

The `mfa` keys in the server configuration are the deployment's policy. Each organization
and each client carries its own, and a sign-in runs under the three narrowed together. A
level can remove a method, add the requirement, keep passwordless sign-in closed, remember
a browser for less time, spare in fewer zones, always challenge, or add rules; it can
never loosen what the level above says. A write that states more than the levels above
allow is refused, naming the setting and the bound.

| Setting | Deployment key | Organization and client field | Meaning |
|---|---|---|---|
| Methods | `mfa.methods` | `methods` | The methods a user may enrol and answer with: `totp`, `passkey`, `email`. Empty inherits the level above; the deployment's empty list means `totp` and `passkey`, so the emailed code is on only where a level names it. |
| Required | `mfa.required` | `required` | A user with no permitted factor is sent through enrolment before a session is issued, at sign-in and at self-service registration. |
| Passwordless | `mfa.passwordless` | `passwordless` | Opens [sign-in with a passkey alone](https://www.nauthera.io/docs/v1/sign-in-flows/passkeys#sign-in-with-a-passkey-alone). Open only where all three levels open it. |
| Remembered browser | `mfa.remember_browser_for` | `remember_for_seconds` | Spares the challenge on a browser that passed one for the user within this window. |
| Spare zones | `mfa.spare_zones` | `spare_zones` | Spares the challenge when the sign-in comes from one of these deployment zones. |
| Always challenge | — | `always_challenge` | Spares no sign-in, on a remembered browser or in a zone. |
| Rules | `mfa.rules` | `rules` | Narrow the level's policy for the sign-ins that meet them. See [Rules](#rules). |

The organization's policy is set with `spec.secondFactor` on the
[Organization resource](https://www.nauthera.io/docs/v1/organization-resource), with
`nauthera-cli create org` or `update org --second-factor-file <file>`, or with
`second_factor` on `POST /v1/admin/orgs` and `PATCH /v1/admin/orgs/{slug}`. A client's
is set with `spec.secondFactor` on the [OIDCClient resource](https://www.nauthera.io/docs/v1/oidc-client) or
`nauthera-cli create client --second-factor-file <file>`; the admin API returns it as
`second_factor` on `GET /v1/admin/orgs/{slug}/clients/{id}`. Every write replaces the
whole policy. The Organization and OIDCClient resources name `totp` and `passkey`; a
policy that names `email` is set with `mfa.methods`, `nauthera-cli` or the admin API.

A user whose only factor the resolved policy does not permit is sent to enrol a
permitted one; they are never signed in on the password. An organization's own admin
dashboard clients run under the organization's policy and always challenge, because an
admin operation's step-up needs a sign-in that used the factor.

## Mandatory enrolment

With `required` set at any level, a user with no permitted factor who signs in, or
registers, is sent through `/mfa/setup` before the flow continues. When more than one
method is permitted, the user chooses. `mfa.required: true` on the deployment needs
`mfa.provider: file`.

Acknowledging the recovery codes completes the sign-in without another code, so it is
tied to the sign-in that activated the factor. Activation binds the factor to that
pending sign-in and ends every other sign-in the user has in progress; an
acknowledgement from any other pending sign-in is refused. Someone else who holds the
password and started a sign-in while the owner was enrolling cannot complete it on the
owner's activation, and has to answer the new factor's challenge.

## Network zones

A network zone is a name for a set of address ranges. The deployment declares its zones
in `security.network_zones` (config file only):

```yaml
security:
  network_zones:
    office: ["203.0.113.0/24", "2001:db8:1::/48"]
    vpn: ["198.51.100.7/32"]
```

An organization declares its own beside them, with `spec.networkZones`,
`nauthera-cli create org` or `update org --zone name=cidr[,cidr...]` (`--clear-zones`
removes them), or `network_zones` on the admin API. An organization's zone may not take a
deployment zone's name. A name is a lowercase label; a range that covers every address of
its family is refused. A sign-in is in a zone when its client address, as
[`security.client_ip`](https://www.nauthera.io/docs/v1/configuration#the-client-address) reads it, falls in one
of the ranges. `GET /v1/admin/deployment/client-ip` lists the deployment's zones.

Every sign-in's audit record, completed or refused, names its zones (`network_zones`).

## Sparing the challenge

A user who holds a permitted factor is not asked for it when either holds:

- **A remembered browser.** The browser passed a second factor for this user within the
  resolved `remember_browser_for` window, while their password and factors are the ones
  it passed under. Changing the password or a factor forgets every browser. The
  deployment's `mfa.remember_browser_for` is the longest window any level may use, at most
  `4320h`; `0` remembers none. An administrator forgets a user's remembered browsers with
  `DELETE /v1/admin/orgs/{slug}/users/{id}/devices/remembered`, for one browser or all of
  them.
- **A spare zone.** The sign-in comes from one of the resolved spare zones. Only
  deployment zones can spare; an organization or a client can only name fewer of them.

Enrolment is never spared by the policy. A spared sign-in is single-factor: its session and
ID token carry the first factor's `amr` and no `mfa`, and its audit record says
`mfa_spared: remembered_browser` or `mfa_spared: network_zone`.

An [identity provider](https://www.nauthera.io/docs/v1/identity-provider-resource#trust-settings) can also be set
to spare the second factor (`spareSecondFactor`), for a provider such as Google or GitHub
that reports nothing about how its user signed in. A sign-in through it that the provider
did not vouch for under `trustMfa` is neither challenged nor sent to enroll, whatever the
policy and its rules say. It is single-factor too (`amr` `fed`), and its audit record says
`mfa_spared: identity_provider`.

## Rules

A rule narrows its level's policy for the sign-ins that meet every condition it sets:

| Condition | Holds when |
|---|---|
| `outside_zones` | The address is in none of these zones. |
| `inside_zones` | The address is in one of these zones. |
| `new_network` | The user has completed no sign-in from this network. |
| `new_browser` | The browser has not passed a second factor for the user under their current password and factors. |

A rule that holds then narrows the methods (`methods`), requires a second factor
(`require`, which no remembered browser or zone spares), or both. A rule cannot loosen
anything. Deployment rules name deployment zones; an organization's and its clients'
rules may name the organization's zones too. Every step of a sign-in evaluates the rules
from its own request, so no step past the password escapes one.

```yaml
mfa:
  rules:
    - name: away              # a second factor outside the office
      outside_zones: [office]
      require: true
    - name: strange-browser   # only a passkey from a browser not seen before
      new_browser: true
      methods: [passkey]
```

A rule needs a name unique on its level, at least one condition and at least one effect;
anything else refuses startup (or the write, on an organization or client). A sign-in's
audit record names the rules that held, by level (`mfa_rules`, for example
`organization:away`).

## Passkeys

Passkeys are served when `mfa.passkey.rp_id` names the relying party: the issuer's host,
or a registrable domain it is under. A passkey that verified its user (fingerprint, face
or PIN) is a second factor on its own; where passwordless sign-in is open it signs the
user in with no password and a session at AAL2. Where it is open, a user who holds no
passkey is offered one after a password sign-in that needed no second factor, or after
answering with a one-time code. See
[Passkeys](https://www.nauthera.io/docs/v1/sign-in-flows/passkeys).

| Key | Default | Meaning |
|---|---|---|
| `mfa.passkey.rp_id` | `""` | The relying-party identifier. Empty serves no passkey ceremony. A public suffix, a domain the issuer is not under, or an address-literal issuer refuses startup. |
| `mfa.passkey.rp_name` | `Nauthera` | The name an authenticator shows when it creates a credential. |
| `mfa.passkey.origins` | `[]` | The origins a ceremony may run on, each under `rp_id`. Empty means the issuer's origin. |
| `mfa.passkey.user_verification` | `preferred` | `required`, `preferred` or `discouraged`. Under `required` a ceremony whose authenticator did not verify its user is refused. |
| `mfa.passkey.timeout` | `2m` | How long a ceremony and its challenge last, `30s` to `10m`. |
| `mfa.passkey.offer_interval` | `720h` | How long a declined passkey offer stays declined, at least `24h`. |

Listing, renaming and removing passkeys (#225, #235), a way back in for a user whose only
factor is a passkey (#226), and letting a passkey fill the sign-in form (#239) are
planned.

## Changing a factor

Activating TOTP or an emailed-code factor, regenerating recovery codes and disabling TOTP
end every other session of the user and retire every refresh grant they were given, so a
session or refresh token taken before the change stops working. The session history
records the end reason `second_factor_changed`.

## Lockout and the acceptance window

- **Window.** `mfa.drift` (default `1`) accepts codes from that many 30-second steps on
  either side of the current one. Values outside 0–2 are a startup error, so at most five codes are ever
  valid at once.
- **Lockout.** Two per-account budgets limit guessing. Both use `mfa.max_failures` (5)
  as the limit and lock for `mfa.lock_duration` (15m). Wrong TOTP codes, refused
  passkeys, wrong first codes at activation and wrong recovery codes all count toward the
  second-factor budget. When a wrong code or refused passkey brings it to or past the
  limit, the second factor is locked, including the TOTP check that regenerating recovery
  codes and disabling TOTP require. Only wrong recovery codes count toward the recovery
  budget, and reaching its limit locks recovery-code redemption alone. Each lock leaves
  the other open: a user whose second factor is locked can still sign in with a recovery
  code. A successful verification clears both budgets, and both counts restart when a
  lock expires. An attempt is reserved before it is scored, so concurrent guesses cannot
  outrun the lock.
- **Replay.** A code that has been accepted cannot be used again within its window.

## Seed storage

TOTP seeds are encrypted at rest with AES-256-GCM under
`mfa.encryption_key`. During a key rotation, previous keys go in `mfa.retired_keys` and
are used only for decryption.

| `mfa.provider` | Use |
|---|---|
| `memory` | Development only, and the default. The key is generated per process, so seeds do not survive a restart. Refused for an issuer that is not loopback, and whenever `mfa.required` is true. |
| `file` | Production. Supply the key with `NAUTHERA_SERVER_MFA_ENCRYPTION_KEY_FILE`. |

## Configuration

```yaml
mfa:
  required: false            # send users with no permitted factor through enrolment
  methods: [totp, passkey]   # the methods offered at all
  passwordless: false        # open sign-in with a passkey alone
  remember_browser_for: 0s   # 0 remembers no browser; at most 4320h
  spare_zones: []            # security.network_zones that spare the challenge
  rules: []                  # see Rules
  provider: file             # memory (dev) | file
  issuer: "Acme"             # label shown in authenticator apps
  drift: 1                   # ±steps accepted, 0–2
  max_failures: 5
  lock_duration: 15m
  recovery_code_count: 10
  passkey:
    rp_id: auth.example.com  # empty serves no passkeys
    rp_name: "Acme"
    user_verification: preferred
    timeout: 2m
    offer_interval: 720h
```

`mfa.passwordless: true` with an `mfa.methods` list that leaves out `passkey` refuses
startup, as does a spare zone or rule zone that `security.network_zones` does not declare.

## Endpoints

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/v1/auth/mfa` | The signed-in user's MFA status |
| `GET` | `/v1/auth/mfa/totp/enroll` | Begin TOTP enrolment |
| `POST` | `/v1/auth/mfa/totp/activate` | Activate with a first code |
| `POST` | `/v1/auth/mfa/totp/recovery-ack` | Acknowledge recovery codes |
| `POST` | `/v1/auth/mfa/totp/verify` | Answer a TOTP challenge |
| `POST` | `/v1/auth/mfa/email/enroll` | Send the code that enrols the emailed-code factor |
| `POST` | `/v1/auth/mfa/email/activate` | Activate with that code |
| `POST` | `/v1/auth/mfa/email/send` | Send a sign-in code |
| `POST` | `/v1/auth/mfa/email/verify` | Answer with the emailed code |
| `POST` | `/v1/auth/mfa/passkey/registration/options` | Begin registering a passkey |
| `POST` | `/v1/auth/mfa/passkey/registration/verify` | Finish registering a passkey |
| `POST` | `/v1/auth/mfa/passkey/authentication/options` | Begin a passkey challenge |
| `POST` | `/v1/auth/mfa/passkey/authentication/verify` | Answer a passkey challenge |
| `POST` | `/v1/auth/mfa/passkey/offer/decline` | Decline the offered passkey |
| `POST` | `/v1/auth/passkey/options` | Begin a passwordless sign-in |
| `POST` | `/v1/auth/passkey/verify` | Finish a passwordless sign-in |
| `POST` | `/v1/auth/mfa/recovery-code/verify` | Answer with a recovery code |
| `POST` | `/v1/auth/mfa/recovery-codes` | Regenerate recovery codes |
| `DELETE` | `/v1/auth/mfa/totp` | Disable TOTP |

The status, regenerate and disable endpoints exist on the server, but the hosted UI
has no account screen that calls them.

## Audit events

Enrolment, activation, verification, recovery-code use, regeneration, lockout and
disabling are all recorded, for example `mfa.totp.activated`, `mfa.totp.verified`,
`mfa.passkey.registered`, `mfa.passkey.verified`, `mfa.passkey.offer_declined`,
`mfa.recovery_code.consumed` and `mfa.factor.locked`. Activation, disabling,
recovery-code use, regeneration and lockout are recorded whatever `audit.level` and
`audit.exclude` say. Sign-in records carry `network_zones`, `mfa_rules` and `mfa_spared`,
and forgetting remembered browsers is recorded as `user.remembered_browsers.forgotten`.
See [Audit Log](https://www.nauthera.io/docs/v1/audit-log).
