# Add two-factor authentication

Add two-factor authentication with the `<MfaSettings/>` component from `@lessly/users-react`. Users can enroll a factor and manage backup codes; Lessly Users handles the second-factor challenge at sign-in.

## What your users get

**An authenticator app.** Users enroll a TOTP factor by scanning a QR code and confirming a six-digit code from an app that supports TOTP.

**Single-use backup codes.** Confirming the factor hands the user a batch of codes — ten by default — shown once. They are the way back in when the phone is gone, each works once, and the remaining count is shown at sign-in so running low is visible before it is a problem.

**A passkey, if you have turned them on.** A user who holds a passkey holds a verified factor, so it appears in their factor list and is offered as an answer to the second-factor challenge alongside the app's code. A passkey sign-in that the device verified is already two factors and skips the challenge entirely — [Add passkeys](/ship/users/passkeys) has that in full.

**Every way in, gated.** Once a user has a verified second factor, all of your sign-in methods are behind it — password, email one-time code, magic link, Google and GitHub alike — and a password reset does not bypass it. Access to someone's inbox is not a way past their second factor.

**A stronger session.** A sign-in that passed a second factor carries `aal: 'aal2'` in its access token, so your backend can demand it on the routes that deserve it. [Sessions and tokens](/ship/users/sessions-and-tokens) has the claim.

> **NOTE**
> Enrolment is the user's choice, not yours — you can require a second factor for the product, but you cannot enrol somebody. The screen below is where they do it.

## Drop the screen in

```bash
npm install @lessly/users-react @lessly/users-client
```

`<MfaSettings/>` needs `@lessly/users-react` 0.9.0 or later.

```tsx

export function SecuritySettings() {
  return (
    <SignedIn>
      <MfaSettings />
    </SignedIn>
  )
}
```

That is the whole integration. `<MfaSettings/>` renders nothing when nobody is signed in, so the `SignedIn` guard is for your surrounding layout rather than for the component's safety. It needs the `UsersProvider` your application already mounts — see [Use the client libraries](/ship/users/client-libraries) — and it takes the same `theme` as the other prebuilt components, so it looks like the rest of your product without being styled twice:

```tsx
<MfaSettings
  heading="Two-step verification"
  chrome="none"
  theme={{ productName: 'Acme', accentColor: '#4f46e5', borderRadius: '8px' }}
  onChange={(factors) => console.log(factors.length)}
/>
```

`chrome="none"` drops the card and draws on your own surface, for a settings page that already has its own panels. `onChange` fires after a factor is confirmed, its backup codes are regenerated, or it is disabled — the place to refresh whatever else on the page describes the account's security.

**If you already render `<UserProfile/>`, you are done.** Its **Security** section embeds this same flow, so a product with the account panel in it has full two-factor self-service already and needs no separate screen. Use `<MfaSettings/>` when you want the two-step section on a page of your own.

## What enrolment looks like

The component is a small flow, and the user walks it once:

1. **The factor list.** What is enrolled, or an empty state with **Set up**.
2. **The QR code.** Rendered by the exported `<QrCode/>`, alongside the secret in copyable text for an authenticator that will not use a camera.
3. **The code.** The six digits from the app. Nothing is enrolled until this passes — a QR code that was scanned and never confirmed leaves the account exactly as it was.
4. **The backup codes.** Shown once, with copy and download. Tell the user plainly that this is the sheet they will want when the phone is gone; there is no second showing, only a regeneration that invalidates the old batch.
5. **Regenerate and disable.** From the factor list afterwards.

**Fresh authentication is required.** Enrolling, regenerating and disabling are sensitive, so the server asks for a recent sign-in and, once a factor exists, for a second-factor proof. The component renders that prompt itself and carries on where it left off — you do not write a step-up screen, and you do not have to handle `StepUpRequiredError` on this path.

**Refusals are shown as sentences.** Two are worth knowing about, because they are policy rather than error: `last_factor_required`, when your configuration requires a second factor and this is the user's last verified one, and `last_way_in`, when removing it would leave the account with no way to sign in at all. Both are rendered human-readably and leave the factor in place.

## Signing in afterwards

Nothing to add. The prebuilt `<SignIn/>` already renders the challenge — the code field, the link to use a backup code, and a passkey button when the attempt named one — when the first factor passes and the account has a factor. Writing your own form instead, the flow answers `needs_second_factor` and you submit `handle.submitSecondFactor(code)`; [Run a sign-in flow](/ship/users/auth-flows) and [Use the client libraries](/ship/users/client-libraries) have that path in full.

## When someone loses their phone

**Backup codes first.** One of the codes from the sheet is submitted at the challenge in place of the app's six digits. Codes are single-use, and the user is told how many remain.

**Or a passkey.** If the user enrolled one, it answers the challenge on its own and no code is needed.

**Then an operator.** If both the app and the codes are gone, someone on your side resets the factors from that user's record — **Reset MFA** on the **MFA factors** section of the user's detail page.

> **WARNING**
> The reset is deliberately not instant: it takes a written reason, is recorded in the audit trail, waits out the delay in your configuration, ends every session the user has, and emails them a way to stop it while it is pending. These checks apply before the reset takes effect. [Manage your end-users](/ship/users/user-management) has the screen.

There is no other path. Nobody at Lessly can lift a second factor off one of your users.

## Make it mandatory

`mfa.required` in your product's configuration turns the second factor from opt-in into an assurance floor: a user with one may no longer unenrol their last verified factor. [Configure authentication](/ship/users/configuration) covers that field, the size of the backup-code batch, the reset delay, and the lockout ceilings on wrong codes.

## Next steps

- [Configure authentication](/ship/users/configuration): the `mfa` policy block, field by field.
- [Use the client libraries](/ship/users/client-libraries): `<MfaSettings/>`, `<QrCode/>` and the rest of the prebuilt components.
- [Run a sign-in flow](/ship/users/auth-flows): the second-factor challenge inside the sign-in flow.
- [Add passkeys](/ship/users/passkeys): passkeys, as a first factor and as this second one.
- [Manage your end-users](/ship/users/user-management): the operator's reset and the audit trail.
