---
title: Two-step verification
description: A code from an authenticator app after sign-in. Off, optional or required per environment, required per organization, and checked on your server.
---

import { ComponentPreview } from '../../../previews/ComponentPreview';

Two-step verification (MFA) asks for a 6-digit code from an authenticator
app after someone signs in, whichever way they signed in: password, magic
link, email code or ZevID. Google Authenticator, 1Password, Authy and
every other app that reads a QR code work.

When someone sets it up they also get ten single-use recovery codes, for
the day they lose their phone.

## Choosing who uses it

Set the policy in the console under **Authentication → Two-step
verification**. It applies to one environment.

| Policy | What happens |
| --- | --- |
| `optional` (default) | People can set it up from their profile. Once they have, they are asked for a code every time they sign in. |
| `required` | Everyone must set it up. People without it are asked to before they can use your app. |
| `off` | Nobody is asked, and nobody can set it up. Anyone who already had it keeps it for when you turn it back on. |

Nothing changes for anyone until they set it up or you choose `required`.
Every sign-in response stays the same until then.

**Organizations can require it for their own members.** Set
`requireMfa: true` on an organization, from your backend
(`PATCH /v1/organizations/:id`) or by an organization admin with
`org:manage` (`PATCH /v1/me/organization`). Members without it are asked
to set it up while they act in that organization. This applies while the
environment's policy is not `off`.

## Signing in

A sign-in from someone with two-step verification returns a challenge
instead of a session:

```json
{
  "status": "needs_second_factor",
  "challenge": {
    "id": "…",
    "factors": ["totp", "recovery_code"],
    "expiresAt": "2026-11-01T12:05:00.000Z"
  }
}
```

Finish it with a code from their app, or one of their recovery codes:

```http
POST /v1/auth/mfa/verify
{ "challengeId": "…", "code": "123456" }
```

That returns the same `{ user, session }` the sign-in would have.

With the JavaScript SDK this is two calls, and the SDK holds the challenge
for you:

```ts
const result = await client.signIn.withPassword({ identifier, password });

if (result.status === 'needs_second_factor') {
  // Ask for the code, then:
  await client.signIn.verifySecondFactor({ code });
}
```

`client.secondFactor` (and `secondFactor` on the state your listeners
receive) is non-null while a sign-in is waiting for its code.

A challenge lasts five minutes and allows five wrong codes. Too many wrong
codes for one person locks second factors for a while, and fires your
`user.locked` webhook. The lock is short: it is there to stop guessing.

## With the components

The React components handle all of this for you. Nothing appears for
anyone who hasn't set up two-step, so existing screens don't change.

**The second step.** When a sign-in needs a code, `<SignIn>` (and
`<SignUp>`) replace the form with a code field. It's set for one-time
codes, so phones offer a code they just received and bring up the number
pad. It includes a way to use a recovery code instead, and a way back to
sign in. This also works when the first step happened before the page
loaded, such as a magic link or a ZevID sign-in.

<ComponentPreview name="sign-in-second-factor" client:load />

When you turn on [remembering devices](#remembering-devices), the step
adds a "Remember this device" checkbox.

**Setup when it's required.** Under `required`, a sign-in ends with a
setup task. `<SignIn>` shows the setup by itself: scan a QR code, enter
the first code, save the recovery codes. Once they're saved,
`afterSignInUrl` or your `children` take over as usual. To catch someone
who arrives with the task some other way (a reload, a restored session),
put `<TaskSetupMfa />` on the page. It renders nothing unless the task is
pending.

```tsx
<TaskSetupMfa afterSetupUrl="/dashboard" />
```

<ComponentPreview name="task-setup-mfa" client:load />

**In the profile.** `<UserProfile>` gets a two-step section under
Security, for turning it on and off and making new recovery codes. It
shows automatically when your app requires two-step or the person
already has it, so anyone enrolled can always manage it. Use
`showMfa` to always show it (to let people opt in under `optional`), or
`showMfa={false}` to hide it.

```tsx
<UserProfile showMfa />
```

<ComponentPreview name="user-profile-mfa" client:load />

Every word can be changed through the `mfa` group in
[`localization`](/concepts/appearance/), and the new parts have element
keys (`codeInput`, `checkbox`, `qrCode`, `secretKey`, `recoveryCodes`).

## Setting it up yourself

From a signed-in session:

```ts
const { otpauthUri, secret } = await client.me.mfa.startTotp();
// Show otpauthUri as a QR code, and `secret` for typing in by hand.

const { recoveryCodes } = await client.me.mfa.confirmTotp({ code });
// Show the recovery codes once, and ask the person to save them.
```

`client.me.mfa.status()` lists their methods and how many recovery codes
remain. `removeFactor({ factorId, code })` and
`regenerateRecoveryCodes({ code })` both need a current code first. Under
`required`, the last method cannot be removed.

| Route | |
| --- | --- |
| `GET /v1/me/mfa` | Methods, recovery codes left, and the policy |
| `POST /v1/me/mfa/totp` | Start setting up an authenticator app |
| `POST /v1/me/mfa/totp/confirm` | Finish with the first code; returns recovery codes |
| `POST /v1/me/mfa/factors/:id/remove` | Remove a method (needs a code) |
| `POST /v1/me/mfa/recovery-codes` | Replace the recovery codes (needs a code) |

## When it is required: session tasks

Under `required`, someone without a second factor still gets a session,
but it carries a task:

- `session.pendingTasks` is `["setup_mfa"]` in the sign-in response, and
  `client.pendingTasks` says the same.
- The access token carries `pending_tasks: ["setup_mfa"]`.
- Our API refuses that session with `session_task_pending`, except for
  what it needs to finish the task: reading the user, the routes above,
  and signing out.

Once they confirm a code, the SDK refreshes the session and the task is
gone. The claim is absent from every token that has nothing pending, so
nothing changes for anyone else.

## Checking on your server

`@zevauth/backend/verify` and `@zevauth/nextjs/server` read both claims:

```ts
import { requireAuth } from '@zevauth/backend/verify';

// Refuses a session that still has to set up MFA (403 session_task_pending).
const auth = await requireAuth(request, config);

// For one sensitive route: refuse unless this session passed a second factor.
await requireAuth(request, config, { requireMfa: true });
```

`getAuth` reports `pendingTasks` and `isMfaVerified` without refusing. A
session that passed a second factor carries `amr: [..., "mfa"]` in its
token. If you verify tokens yourself, check for `"mfa"` in `amr`.

Routes written before two-step verification existed keep working as
they did. `requireAuth` only refuses tokens that carry `pending_tasks`,
and only environments that require MFA issue them.

## Options

All of these are under **Authentication → Two-step verification** in the
console, and all are off until you change them.

### Remembering devices

Let people tick "Remember this device" at the second step and skip it on
that browser for 7, 30 or 90 days. Changing their password, removing
their authenticator app, or a reset forgets every device straight away.

With the components there is nothing to build. Driving the API yourself,
pass `rememberDevice: true` to `verifySecondFactor`. The SDK keeps the
token it gets back and sends it at the next sign-in as the
`X-Zevauth-Device` header.

### Trusting ZevID's two-step

People signing in with ZevID may already have passed ZevID's own
two-step check. Turn this on to skip your code for them. It takes effect
once ZevID sign-ins report that check; until then, everyone is asked for
your code and the console says so.

### Organizations requiring it

By default, organization admins in your app can require two-step for
their own members. Turn this off to keep that decision for your backend.
The in-app update is then refused with `organization_mfa_setting_locked`,
and `PATCH /v1/organizations/:id` from your backend still works.

## Support: a lost phone

If someone has lost both their phone and their recovery codes, reset
their two-step verification:

- in the console, on the user's page, **Two-step verification → Reset**.
  "Also sign them out everywhere" is on by default, which also ends any
  session left on the lost phone;
- or from your backend: `DELETE /v1/users/:id/mfa`. Add
  `?revokeSessions=true` to sign them out everywhere too. Without it,
  sessions are left as they are.

Both are logged and fire `user.mfa.disabled`. Make sure it is really them
first: a reset removes the second step from their account.

`GET /v1/users` and `GET /v1/users/:id` include `mfaEnabled` for each user.

## Webhooks

| Event | When |
| --- | --- |
| `user.mfa.enabled` | Someone finished setting up an authenticator app |
| `user.mfa.disabled` | Someone removed it (`reason: removed_by_user`), or you reset it (`reason: reset_by_developer`) |
| `user.locked` | Too many wrong codes (`reason: mfa_attempts`), as for passwords |

## Errors

| Code | Meaning |
| --- | --- |
| `mfa_code_invalid` | The code was wrong, expired or already used. |
| `mfa_challenge_expired` | The sign-in took too long or used up its attempts. Sign in again. |
| `mfa_locked` | Too many wrong codes recently. Try again later. |
| `session_task_pending` | The session must set up two-step verification first. |
| `mfa_required` | The route needs a session that passed a second factor. |
| `mfa_already_enrolled` | An authenticator app is already set up. |
| `mfa_enrollment_not_started` | Confirm was called before start. |
| `mfa_disabled` | The environment's policy is `off`. |
| `mfa_factor_not_found` | No such method on this account. |
| `organization_mfa_setting_locked` | An organization admin tried to change "require two-step", and your console keeps that for your backend. |

> **Note:**
> How secrets are stored: authenticator secrets are encrypted with a key
> unique to your environment, which is itself encrypted with our master
> key. Recovery codes are stored only as keyed hashes. Neither is ever
> shown again after setup, including to you.
