ZevAuth
Sign up

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.

PolicyWhat 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.
requiredEveryone must set it up. People without it are asked to before they can use your app.
offNobody 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.

Live preview
Northwind

Two-step verification

Enter the 6-digit code from your authenticator app.

Secured by ZevAuth

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" />
Live preview
Northwind

Set up two-step verification

This app asks for a code from an authenticator app when you sign in.

Secured by ZevAuth

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.

<UserProfile showMfa />
Live preview
8 characters or more. Your other devices will be signed out.

Two-step verification
Ask for a code from an authenticator app when you sign in, as well as your password.

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/mfaMethods, recovery codes left, and the policy
POST /v1/me/mfa/totpStart setting up an authenticator app
POST /v1/me/mfa/totp/confirmFinish with the first code; returns recovery codes
POST /v1/me/mfa/factors/:id/removeRemove a method (needs a code)
POST /v1/me/mfa/recovery-codesReplace 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:

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

EventWhen
user.mfa.enabledSomeone finished setting up an authenticator app
user.mfa.disabledSomeone removed it (reason: removed_by_user), or you reset it (reason: reset_by_developer)
user.lockedToo many wrong codes (reason: mfa_attempts), as for passwords

Errors

CodeMeaning
mfa_code_invalidThe code was wrong, expired or already used.
mfa_challenge_expiredThe sign-in took too long or used up its attempts. Sign in again.
mfa_lockedToo many wrong codes recently. Try again later.
session_task_pendingThe session must set up two-step verification first.
mfa_requiredThe route needs a session that passed a second factor.
mfa_already_enrolledAn authenticator app is already set up.
mfa_enrollment_not_startedConfirm was called before start.
mfa_disabledThe environment’s policy is off.
mfa_factor_not_foundNo such method on this account.
organization_mfa_setting_lockedAn organization admin tried to change “require two-step”, and your console keeps that for your backend.

Updated at, Sunday, November 1, 2026