Skip to content

The v1 docs follow nauthera-server's main branch and its 0.x releases on the way to 1.0, so fields and APIs can still change.

v1

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

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

SettingDeployment keyOrganization and client fieldMeaning
Methodsmfa.methodsmethodsThe 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.
Requiredmfa.requiredrequiredA user with no permitted factor is sent through enrolment before a session is issued, at sign-in and at self-service registration.
Passwordlessmfa.passwordlesspasswordlessOpens sign-in with a passkey alone. Open only where all three levels open it.
Remembered browsermfa.remember_browser_forremember_for_secondsSpares the challenge on a browser that passed one for the user within this window.
Spare zonesmfa.spare_zonesspare_zonesSpares the challenge when the sign-in comes from one of these deployment zones.
Always challenge—always_challengeSpares no sign-in, on a remembered browser or in a zone.
Rulesmfa.rulesrulesNarrow 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 /v1/admin/orgs/{slug}. 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 /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):

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 /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 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:

ConditionHolds when
outside_zonesThe address is in none of these zones.
inside_zonesThe address is in one of these zones.
new_networkThe user has completed no sign-in from this network.
new_browserThe 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.

KeyDefaultMeaning
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_nameNautheraThe 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_verificationpreferredrequired, preferred or discouraged. Under required a ceremony whose authenticator did not verify its user is refused.
mfa.passkey.timeout2mHow long a ceremony and its challenge last, 30s to 10m.
mfa.passkey.offer_interval720hHow 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.providerUse
memoryDevelopment 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.
fileProduction. Supply the key with NAUTHERA_SERVER_MFA_ENCRYPTION_KEY_FILE.

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: 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

MethodPathPurpose
GET/v1/auth/mfaThe signed-in user's MFA status
GET/v1/auth/mfa/totp/enrollBegin TOTP enrolment
POST/v1/auth/mfa/totp/activateActivate with a first code
POST/v1/auth/mfa/totp/recovery-ackAcknowledge recovery codes
POST/v1/auth/mfa/totp/verifyAnswer a TOTP challenge
POST/v1/auth/mfa/email/enrollSend the code that enrols the emailed-code factor
POST/v1/auth/mfa/email/activateActivate with that code
POST/v1/auth/mfa/email/sendSend a sign-in code
POST/v1/auth/mfa/email/verifyAnswer with the emailed code
POST/v1/auth/mfa/passkey/registration/optionsBegin registering a passkey
POST/v1/auth/mfa/passkey/registration/verifyFinish registering a passkey
POST/v1/auth/mfa/passkey/authentication/optionsBegin a passkey challenge
POST/v1/auth/mfa/passkey/authentication/verifyAnswer a passkey challenge
POST/v1/auth/mfa/passkey/offer/declineDecline the offered passkey
POST/v1/auth/passkey/optionsBegin a passwordless sign-in
POST/v1/auth/passkey/verifyFinish a passwordless sign-in
POST/v1/auth/mfa/recovery-code/verifyAnswer with a recovery code
POST/v1/auth/mfa/recovery-codesRegenerate recovery codes
DELETE/v1/auth/mfa/totpDisable 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.