Multi-Factor Authentication
Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated
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 and Passkeys.
How it works
- Enrolment. A user enrols on
/mfa/setup. For TOTP the server generates a seed and returns anotpauth://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 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. - 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 is checked again at this step, so an account suspended while the code was being entered gets no session. - 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, 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.
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. 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. |
The organization's policy is set with spec.secondFactor on the
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 /. A client's
is set with spec.secondFactor on the OIDCClient resource or
nauthera-cli create client --second-factor-file <file>; the admin API returns it as
second_factor on GET /. 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):
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 reads it, falls in one
of the ranges. GET / 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_forwindow, while their password and factors are the ones it passed under. Changing the password or a factor forgets every browser. The deployment'smfa.remember_browser_foris the longest window any level may use, at most4320h;0remembers none. An administrator forgets a user's remembered browsers withDELETE /, for one browser or all of them.v1/ admin/ orgs/ {slug}/ users/ {id}/ devices/ remembered - 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_ or mfa_spared: network_zone.
An identity provider 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_.
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.
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.
| 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. | 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. | 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(default1) 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 formfa.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_. |
Configuration
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: 720hmfa.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 | / | Activate with a first code |
POST | / | Acknowledge recovery codes |
POST | /v1/auth/mfa/totp/verify | Answer a TOTP challenge |
POST | / | Send the code that enrols the emailed-code factor |
POST | / | Activate with that code |
POST | /v1/auth/mfa/email/send | Send a sign-in code |
POST | / | Answer with the emailed code |
POST | / | Begin registering a passkey |
POST | / | Finish registering a passkey |
POST | / | Begin a passkey challenge |
POST | / | Answer a passkey challenge |
POST | / | 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 | / | Answer with a recovery code |
POST | / | 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.,
mfa. 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..
See Audit Log.