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.
Overview
A three-stage flow: credentials → OTP → success. 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.
Sign in
Welcome back to Mukamba Group.
Verify it's you
Enter the 6-digit code we sent.
Resend in 0:42
You're in
Signed in as tendai@mukamba.co. We'll skip the code on this device for 30 days.
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
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.
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.
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.
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.
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.
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.
Sign in
Welcome back to Mukamba Group.
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
stageflips to'otp', the first OTP box getsautoFocus. 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
Alertcarries anidand the form/OTP carries a matchingaria-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
disabledwhile 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.
Checkboxwraps the label so click-on-text works and screen readers announce its state.