# Two-Factor Sign-In

> Coming soon, upon request — this feature is turned on for your tenant by the Aurea team. [Contact us](https://www.aureahub.com/contact)

A second factor at sign-in for the accounts that run your tenant: an authenticator app's six-digit code after the password, and ten recovery codes for the day the phone is lost.

## Overview

- **For every role but the End User**: `owner`, `tenantadmin`, `non_signing_admin`, `signer`, `approver`, `editor`, `viewer`, `auditor`, `security_admin` and `security_auditor` ([Roles](https://docs.aureahub.com/docs/authentication.md)). An End User's sign-in (`user`) never changes: its management routes answer `403` `MFA_NOT_AVAILABLE`.
- **An authenticator app** — any app that reads an `otpauth://` QR code (TOTP, RFC 6238: SHA-1, 6 digits, a new code every 30 seconds) — and **ten recovery codes**, each usable once instead of a code.
- **Asked after the password**: [Login](https://docs.aureahub.com/docs/auth-login.md) answers `401` `MFA_REQUIRED` with an `mfaToken`, and [Sign In: Second Step](https://docs.aureahub.com/docs/mfa-verify.md) gives the tokens for the code — once. A password reset asks it too, instead of signing straight back in (After a Password Reset).
- **Each account manages its own factor** with its access token (the routes under **Two-Factor Sign-In** in the API Reference); an administrator resets another user's (A Lost Phone).
- **Passkeys are not a second factor**: they sign no one in, and confirm one sensitive action at a time once signed in — [Passkeys & Step-Up](https://docs.aureahub.com/docs/guide-passkeys.md).
- Written to your tenant's [audit trail](https://docs.aureahub.com/docs/audit-events.md) [Coming soon, upon request](https://docs.aureahub.com/docs/feature-availability.md) (category `access`): `mfa.enrolled`, `mfa.recovery_codes_regenerated`, `mfa.removed`, `mfa.reset`, and `session.started` with `via` `password+totp` or `password+recovery_code` — never a secret or a code.

## Modes

Two-factor sign-in is available on request [Coming soon, upon request](https://docs.aureahub.com/docs/feature-availability.md). [Your Second Factor](https://docs.aureahub.com/docs/mfa-status.md) answers the account's `mode`:

| mode | What the accounts meet |
| --- | --- |
| off | **The default.** Nothing is asked at sign-in. Enrolling answers `409` `MFA_OFF`. |
| log | **Asked of the enrolled.** An account with an authenticator app gives its code at every sign-in; one without signs in with its password as before. This is when your accounts enrol. |
| enforce | **Required.** An enrolled account gives its code; one that is not enrolled is refused at sign-in — `403` `MFA_ENROLLMENT_REQUIRED`, no tokens. Removing the app is refused (`409` `MFA_REMOVAL_REFUSED`): replace it instead. A tenant on the institutional security profile is in `enforce` by its profile. |

- Aurea moves a tenant to `log` first: have every account that signs in enrol then, before `enforce`.
- The second factor is asked at the password sign-in. Sessions already open, and their refresh tokens, keep working.

## Enrol an Authenticator App

1. [Enrol an App](https://docs.aureahub.com/docs/mfa-totp-start.md): `201` with `secret` (base32), `otpauthUri` and `expiresAt` (15 minutes). Show `otpauthUri` as a QR code — the app lists it under your tenant's name and the username — or let the user type `secret`.
2. [Confirm the App](https://docs.aureahub.com/docs/mfa-totp-confirm.md) with a code the app shows, before `expiresAt`: the app becomes the account's second factor, and the answer carries the ten `recoveryCodes` — shown only now.

```javascript
const auth = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' };

// 1. A new secret: show otpauthUri as a QR code
const enrolment = await fetch('https://api.aureahub.com/v1/auth/mfa/totp', { method: 'POST', headers: auth })
  .then((r) => r.json());
showQrCode(enrolment.otpauthUri);

// 2. The first code the app shows
const { recoveryCodes } = await fetch('https://api.aureahub.com/v1/auth/mfa/totp/confirm', {
  method: 'POST',
  headers: auth,
  body: JSON.stringify({ code: codeTypedByTheUser }), // six digits
}).then((r) => r.json());
showOnce(recoveryCodes); // ten codes: the user stores them safely
```

- The secret and the recovery codes are answered with `Cache-Control: no-store`: never log or keep them on your side.
- An enrolment not confirmed within 15 minutes lapses: `409` `MFA_NO_PENDING` — start again.

## Sign In: the Second Step

For an enrolled account, [Login](https://docs.aureahub.com/docs/auth-login.md) checks the password and answers, instead of the tokens:

```json
{
  "statusCode": 401,
  "error": "MfaRequiredError",
  "message": "A second factor is required",
  "details": {
    "code": "MFA_REQUIRED",
    "mfaToken": "eyJ2IjoxLCJzdWIiOiI…",
    "methods": ["totp", "recovery_code"],
    "expiresIn": 300
  }
}
```

1. Ask the user for the code their app shows (or a recovery code).
2. Within `expiresIn` seconds (5 minutes), send `{ mfaToken, code }` — or `{ mfaToken, recoveryCode }` — to [POST /v1/auth/mfa/verify](https://docs.aureahub.com/docs/mfa-verify.md), signed as the login is signed (the HMAC headers or a server token).
3. The answer is the login's: `accessToken`, `refreshToken`, `expiresIn`, `user`. A `createWallet` sent to the login is not carried over: no `wallet` comes back.

```javascript
async function signIn(username, password, askForCode) {
  const login = await signedPost('/v1/auth/login', { username, password }); // see Login → Implementation
  if (login.status === 200) return login.json();

  const body = await login.json();
  if (login.status === 401 && body.details?.code === 'MFA_REQUIRED') {
    const code = await askForCode(); // six digits from the authenticator app
    const verify = await signedPost('/v1/auth/mfa/verify', { mfaToken: body.details.mfaToken, code });
    if (verify.ok) return verify.json();   // the login's answer
    throw new Error((await verify.json()).details?.code); // MFA_CODE_INVALID: ask again; MFA_CHALLENGE_INVALID: sign in again
  }
  throw new Error(body.details?.code ?? body.message);
}
```

- **The code of the moment**, or the one before or after it (a phone's clock may drift by one step). Each code is accepted once: after signing in with one, the next action that asks for a code takes the next one.
- **A wrong code** answers `401` `MFA_CODE_INVALID`; ask again with the same `mfaToken` while it is valid. It counts as a failed sign-in: where Aurea throttles failed sign-ins for your tenant, too many answer `429` with `Retry-After`.
- **An `mfaToken` signs in once.** Once it gave the tokens, sending it again answers `401` `MFA_CHALLENGE_INVALID`; of two answers sent at the same time, only one signs in. A wrong code does not use it up.
- **An expired or used `mfaToken`** answers `401` `MFA_CHALLENGE_INVALID` (`The sign-in has expired: sign in again`): start again from the password. The `mfaToken` is not an access token: no other route accepts it.

## After a Password Reset

A password reset signs the user straight back in only where the login would ([Forgot Password](https://docs.aureahub.com/docs/auth-forgot-password.md) › Complete the reset). For an account whose second factor is asked, the password is changed and the reset's link is spent, and the answer is `200` without tokens, with the login's own `details`:

- `MFA_REQUIRED`, with `mfaToken`, `methods` and `expiresIn`: answer it at [Sign In: Second Step](https://docs.aureahub.com/docs/mfa-verify.md), as after the login's password.
- `MFA_ENROLLMENT_REQUIRED`: the account must have a second factor and has none — it cannot sign in until it has one, as at the login.
- An End User, an account whose tenant does not ask for second factors, or one in `log` with no app enrolled: the tokens, as before.

Branch on `details.code` after the reset as after the login: a `200` with `details` carries no tokens.

## Recovery Codes

- Ten codes such as `K7M2P-9QXR4`, each usable once in place of a code at the second step (`recoveryCode`). Case, dashes and spaces don't matter.
- [New Recovery Codes](https://docs.aureahub.com/docs/mfa-recovery-codes.md), with a current code of the app: ten new ones, and the old ones stop working. [Your Second Factor](https://docs.aureahub.com/docs/mfa-status.md) tells how many are left (`recoveryCodesLeft`).

## Replace or Remove the App

- **A new phone**: enrol again ([Enrol an App](https://docs.aureahub.com/docs/mfa-totp-start.md), then confirm). The old app stays the factor until the new one is confirmed; then it is replaced, with ten new recovery codes.
- **No second factor**: [Remove the App](https://docs.aureahub.com/docs/mfa-totp-remove.md), with a current code — the app and the recovery codes go. Refused where a second factor is required (`409` `MFA_REMOVAL_REFUSED`).

## A Lost Phone

- With a recovery code: sign in with it, then enrol the new phone.
- Without one: an administrator of your tenant removes the user's factor with [Reset a User's Factor](https://docs.aureahub.com/docs/mfa-reset.md) — under the same rules as any change to another user's account ([Roles](https://docs.aureahub.com/docs/authentication.md)). The user then enrols a new app. Where your tenant's quorum holds administrators' actions, the reset waits for another administrator ([Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md)); a reset that can only be refused is answered at once, never held.
- **An administrator never resets their own factor** while second factors are in use for the account: `403` `MFA_RESET_SELF`. Another administrator resets it; with the phone in hand, replace or remove the app with a current code (Replace or Remove the App).

## Refusals

The usual envelope, the code in `details.code`:

| Status | details.code | message |
| --- | --- | --- |
| 401 | MFA_REQUIRED | `A second factor is required` — the login's answer for an enrolled account, with `mfaToken`, `methods` and `expiresIn` |
| 403 | MFA_ENROLLMENT_REQUIRED | `A second factor is required for this account and none is enrolled` — at the login, where it is required |
| 401 | MFA_CHALLENGE_INVALID | `The sign-in has expired: sign in again` — an `mfaToken` expired, or already used to sign in |
| 401 | MFA_CODE_INVALID | `The code is not correct` — at the second step; it counts as a failed sign-in |
| 400 | MFA_CODE_INVALID | `The code is not correct` — confirming, new recovery codes, removing |
| 403 | MFA_NOT_AVAILABLE | `A second factor is for the accounts that administer` — an End User |
| 409 | MFA_OFF | `Second factors are not in use for this account` |
| 409 | MFA_NO_PENDING | `No enrolment is waiting: start one again` |
| 409 | MFA_NOT_ENROLLED | `No second factor is enrolled` |
| 409 | MFA_REMOVAL_REFUSED | `A second factor is required for this account: enrol a new one instead of removing it` |
| 403 | MFA_RESET_SELF | `An administrator's reset does not remove one's own second factor: use DELETE /v1/auth/mfa/totp with a current code` — Reset a User's Factor on one's own account |

- At the second step, sending both a code and a recovery code, or neither, answers `422` (`Give either a code or a recovery code`). Too many failed sign-ins, where Aurea throttles them: `429` `Too many failed sign-in attempts. Try again in 5 minutes.` with `Retry-After`.
- Rate limits: 20 requests per minute per client IP on each route that takes a code or starts an enrolment, and on the second step.

---

Web version: https://docs.aureahub.com/#guide-two-factor
