Add two-factor authentication
Add the prebuilt screen for two-factor enrollment, backup codes, and factor management.
Add two-factor authentication with the <MfaSettings/> component from @lessly/users-react. Users can enroll a factor and manage backup codes; Lessly Users handles the second-factor challenge at sign-in.
What your users get
An authenticator app. Users enroll a TOTP factor by scanning a QR code and confirming a six-digit code from an app that supports TOTP.
Single-use backup codes. Confirming the factor hands the user a batch of codes — ten by default — shown once. They are the way back in when the phone is gone, each works once, and the remaining count is shown at sign-in so running low is visible before it is a problem.
A passkey, if you have turned them on. A user who holds a passkey holds a verified factor, so it appears in their factor list and is offered as an answer to the second-factor challenge alongside the app’s code. A passkey sign-in that the device verified is already two factors and skips the challenge entirely — Add passkeys has that in full.
Every way in, gated. Once a user has a verified second factor, all of your sign-in methods are behind it — password, email one-time code, magic link, Google and GitHub alike — and a password reset does not bypass it. Access to someone’s inbox is not a way past their second factor.
A stronger session. A sign-in that passed a second factor carries aal: 'aal2' in its access token, so your backend can demand it on the routes that deserve it. Sessions and tokens has the claim.
Drop the screen in
npm install @lessly/users-react @lessly/users-client<MfaSettings/> needs @lessly/users-react 0.9.0 or later.
import { MfaSettings, SignedIn } from '@lessly/users-react'
export function SecuritySettings() {
return (
<SignedIn>
<MfaSettings />
</SignedIn>
)
}That is the whole integration. <MfaSettings/> renders nothing when nobody is signed in, so the SignedIn guard is for your surrounding layout rather than for the component’s safety. It needs the UsersProvider your application already mounts — see Use the client libraries — and it takes the same theme as the other prebuilt components, so it looks like the rest of your product without being styled twice:
<MfaSettings
heading="Two-step verification"
chrome="none"
theme={{ productName: 'Acme', accentColor: '#4f46e5', borderRadius: '8px' }}
onChange={(factors) => console.log(factors.length)}
/>chrome="none" drops the card and draws on your own surface, for a settings page that already has its own panels. onChange fires after a factor is confirmed, its backup codes are regenerated, or it is disabled — the place to refresh whatever else on the page describes the account’s security.
If you already render <UserProfile/>, you are done. Its Security section embeds this same flow, so a product with the account panel in it has full two-factor self-service already and needs no separate screen. Use <MfaSettings/> when you want the two-step section on a page of your own.
What enrolment looks like
The component is a small flow, and the user walks it once:
- The factor list. What is enrolled, or an empty state with Set up.
- The QR code. Rendered by the exported
<QrCode/>, alongside the secret in copyable text for an authenticator that will not use a camera. - The code. The six digits from the app. Nothing is enrolled until this passes — a QR code that was scanned and never confirmed leaves the account exactly as it was.
- The backup codes. Shown once, with copy and download. Tell the user plainly that this is the sheet they will want when the phone is gone; there is no second showing, only a regeneration that invalidates the old batch.
- Regenerate and disable. From the factor list afterwards.
Fresh authentication is required. Enrolling, regenerating and disabling are sensitive, so the server asks for a recent sign-in and, once a factor exists, for a second-factor proof. The component renders that prompt itself and carries on where it left off — you do not write a step-up screen, and you do not have to handle StepUpRequiredError on this path.
Refusals are shown as sentences. Two are worth knowing about, because they are policy rather than error: last_factor_required, when your configuration requires a second factor and this is the user’s last verified one, and last_way_in, when removing it would leave the account with no way to sign in at all. Both are rendered human-readably and leave the factor in place.
Signing in afterwards
Nothing to add. The prebuilt <SignIn/> already renders the challenge — the code field, the link to use a backup code, and a passkey button when the attempt named one — when the first factor passes and the account has a factor. Writing your own form instead, the flow answers needs_second_factor and you submit handle.submitSecondFactor(code); Run a sign-in flow and Use the client libraries have that path in full.
When someone loses their phone
Backup codes first. One of the codes from the sheet is submitted at the challenge in place of the app’s six digits. Codes are single-use, and the user is told how many remain.
Or a passkey. If the user enrolled one, it answers the challenge on its own and no code is needed.
Then an operator. If both the app and the codes are gone, someone on your side resets the factors from that user’s record — Reset MFA on the MFA factors section of the user’s detail page.
There is no other path. Nobody at Lessly can lift a second factor off one of your users.
Make it mandatory
mfa.required in your product’s configuration turns the second factor from opt-in into an assurance floor: a user with one may no longer unenrol their last verified factor. Configure authentication covers that field, the size of the backup-code batch, the reset delay, and the lockout ceilings on wrong codes.
Next steps
- Configure authentication: the
mfapolicy block, field by field. - Use the client libraries:
<MfaSettings/>,<QrCode/>and the rest of the prebuilt components. - Run a sign-in flow: the second-factor challenge inside the sign-in flow.
- Add passkeys: passkeys, as a first factor and as this second one.
- Manage your end-users: the operator’s reset and the audit trail.