Home/ Cookbook/ Auth/ Sign in with 2FA

Build a sign-in with 2-step verification

By the end of this recipe you'll have a working sign-in flow that takes the user from email + password through a 6-digit OTP and lands them on a confirmed-signed-in screen — with error states, resend countdown, and a "trust this device" toggle.

Auth 3 primitives · 0 blocks · 1 pattern ~25 min React · @corelithzw/react

Overview

A three-stage flow: credentialsOTPsuccess. One component, one state machine, one place to handle errors.

2-step verification is the cheapest, biggest security win you can ship. A stolen password is no longer enough; the attacker would also need the operator's phone or authenticator app. For an SMB app — where the same login often unlocks cash drawers, payroll exports and supplier payments — that second factor is what separates "we got phished" from "we tried to get phished".

The recipe keeps the second factor short-code (TOTP-style), not push-based, because (a) it works offline and on a feature phone, and (b) you don't need to run a notification server to ship it. If the user opts in to "trust this device", you skip the OTP step on that device for 30 days.

Skip 2FA if your app holds no money, no PII and no actions that are hard to reverse — e.g. a public storefront, a marketing site, an internal-only kiosk. Adding a second factor to a low-stakes app just teaches users to dismiss prompts.

What you'll build

Three screens, one component. The state machine moves the user forward; the back button moves them back.

Huchu

Sign in

Welcome back to Mukamba Group.

Continue
01 Credentials — email + password
Huchu

Verify it's you

Enter the 6-digit code we sent.

28419

Resend in 0:42

Verify
02 OTP — 6 digits, resend timer

You're in

Signed in as tendai@mukamba.co. We'll skip the code on this device for 30 days.

Go to dashboard
03 Success — confirm + onward

You can see this live in

Portal demos that wire this recipe in context. Open one to watch the state machine fire.

Required pieces

Everything this recipe pulls from @corelithzw/react. Open any reference page for props, variants and the do/don't.

@corelithzw/react exports used here: Stack, Form, Field, Input, InputOtp, Button, Alert, Checkbox, AuthShell. Phase 2 recipes copy these names verbatim — keep the convention.

Step-by-step build

01

Model the flow as a state machine, not three components

The single hardest bug in auth flows is two screens being visible at the same time, or the back button skipping a step. Make the stage a string union — 'credentials' | 'otp' | 'success' — and render exactly one branch. Errors live on the same machine so you never have a "verified but still on the OTP screen" race.

Code-only step

FAIL action sets error without changing stage.

BACK from OTP returns to credentials and clears the error.

Switch to the Code tab to see the snippet.

Step 1 · State machine@corelithzw/react
02

Render the credentials screen with inline error

Put both fields inside one Form so Enter submits, and link the alert above with aria-describedby so a screen reader reads the error together with the form. Don't navigate to a separate error page — that wipes what the user typed.

Sign in

Welcome back to Mukamba Group.

We don't recognise that email + password.
Email
Password
Continue
Step 2 · Credentials
03

Render the OTP screen with a resend countdown

InputOtp handles the per-digit boxes, paste-into-all, and autocomplete="one-time-code" for iOS keyboard SMS suggestion. Run the resend countdown in a tiny useEffect; disable the resend button until the timer hits zero so the user can't spam your SMS budget.

Verify it's you

We sent a 6-digit code to tendai@mukamba.co.

2
8
4
1
9
Verify
Resend in 0:42
Step 3 · OTP + countdown
04

Land the user on a success screen — and tell them what changed

People don't trust silent redirects. The success screen confirms the identity ("signed in as …") and tells the user what their trust-device choice means, then auto-forwards in 2 seconds. The auto-forward gives users who hit "trust" a moment to read the consequence; users who skipped it just see the dashboard a moment later.

You're in

Signed in as tendai@mukamba.co. We'll skip the code on this device for 30 days.

Go to dashboard
Step 4 · Success

Final composition

The whole SignInWith2FA component, assembled. State machine, error paths, resend timer, trust-device, and a wrapper AuthShell from the auth pattern so the centered card + logo come free.

Huchu

Sign in

Welcome back to Mukamba Group.

Email
tendai@mukamba.co
Password
••••••••••
Continue
SignInWith2FA.tsx@corelithzw/react

Variations

Three common forks. Each is a small diff from the final composition — drop them in and the rest of the machine keeps working.

Without 2FA

Low-stakes app, no second factor. Skip straight from credentials to success and lose the OTP stage entirely.

// Drop 'otp' from the union
type Stage = 'credentials' | 'success';

// CREDENTIALS_OK lands you on success
case 'CREDENTIALS_OK':
  return { ...s, stage: 'success', email: a.email };
// remove the <Otp/> render block

Magic-link only

No password at all — email a one-time link. The credentials screen becomes email-only, and the OTP screen becomes a "check your inbox" panel.

// Replace password Field with a hint
<p>We'll email you a one-time link.</p>

// OtpScreen becomes a poller
<Stack>
  <h1>Check your inbox</h1>
  <p>Link sent to <b>{email}</b>.</p>
</Stack>

SSO-first

Workspace uses SSO. Lead with the SSO button and only show the email/password as a fallback link. Skip the OTP — SSO already enforced MFA upstream.

// Credentials becomes a 2-line screen
<Button onClick={signInWithSso}>
  Continue with SSO
</Button>
<Button variant="link"
  onClick={() => setMode('password')}>
  Use email + password
</Button>

Accessibility

What this recipe takes care of for you. Every item below is something a screen-reader or keyboard-only user will notice if you skip it.

  • Focus moves with the stage. When stage flips to 'otp', the first OTP box gets autoFocus. On 'success', the heading gets focus so the next Tab lands on "Go to dashboard".
  • OTP input declares its purpose. autoComplete="one-time-code" + inputMode="numeric" together unlock the iOS keyboard SMS suggestion and pop the number pad on Android.
  • Errors are linked, not just rendered. The Alert carries an id and the form/OTP carries a matching aria-describedby, so a screen reader reads the field name and the error in one breath.
  • Success is announced. The success container uses role="status" + aria-live="polite" so screen readers say "You're in. Signed in as …" without the user needing to navigate to it.
  • Resend disables itself. The button uses real disabled while the countdown runs — it's keyboard-skipped and screen-readers read it as "Resend in 0:42, dimmed", not as a clickable target.
  • The whole flow is keyboard-only. Tab order matches reading order, Enter submits each form, Esc on the OTP screen returns to credentials.
  • Trust-this-device is a real checkbox. Checkbox wraps the label so click-on-text works and screen readers announce its state.