Concepts
Two-step verification
A code from an authenticator app after sign-in. Off, optional or required per environment, required per organization, and checked on your server.
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:
{
"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:
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:
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.
When you turn on 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.
<TaskSetupMfa afterSetupUrl="/dashboard" />
Set up two-step verification
This app asks for a code from an authenticator app when you sign in.
Secured by ZevAuthIn 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.
<UserProfile showMfa />
Every word can be changed through the mfa group in
localization, and the new parts have element
keys (codeInput, checkbox, qrCode, secretKey, recoveryCodes).
Setting it up yourself
From a signed-in session:
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.pendingTasksis["setup_mfa"]in the sign-in response, andclient.pendingTaskssays 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:
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=trueto 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. |
Updated at, Sunday, November 1, 2026