#Two-factor authentication
A realm can ask for a second factor after the password. Two are built in, and they share one mechanism, so switching between them is a settings change rather than a rewrite.
| Method | Where the code comes from | Enrollment | Needs |
|---|---|---|---|
totp |
An authenticator app on the user's phone | Scan a QR code, confirm one code | Nothing |
emailCode |
An email sent at sign-in | None | features.notifications and a verified email address |
TOTP is the stronger of the two and works with no infrastructure at all. Email codes need no enrollment, which is what makes them workable for a public audience you cannot ask to install an app.
When both are enabled, a user with an active TOTP enrollment is only ever offered TOTP; email codes apply to everyone else who has a verified address. Offering the weaker factor next to the stronger one would let an attacker pick it.
⚠️ Email codes and password reset share a mailbox. If password reset also goes through email, a compromised mailbox is both the reset channel and the second factor. The second factor then adds nothing against the attacker who matters. Prefer TOTP wherever you can ask users to install an app.
#Turning it on
1import { $realm } from "alepha/api/users"; 2 3class AuthService { 4 realm = $realm({ 5 settings: { 6 mfa: { 7 totp: "required", 8 emailCode: "disabled", 9 },10 },11 });12}
Each method takes "disabled", "optional" or "required":
disabled(the default): never offered. Enrollment is refused server-side, and the account page hides the row.optional: users may enroll from their account page, and are challenged once they have.required: the same, plus your UI should push an unenrolled user through enrollment.
Turning totp back to disabled does not delete the enrollments already made. Those accounts are no longer challenged, so the factor stops applying, but the row stays on the account page saying the site no longer asks for a code, with the option to remove it. Hiding it would leave a user with a factor they can neither see nor clear.
⚠️ required does not block an unenrolled user. An account with nothing enrolled has no second factor to be challenged on, so it signs in on the password alone. This is deliberate: enforcing it at the login route would lock out every existing account the moment you switched the setting on, which is nobody's intended rollout.
Enforce it in your UI instead. settings.mfa.totp === "required" in the realm config (GET /api/realms/config, public) tells you the policy, and GET /api/users/me/mfa tells you whether this user has satisfied it (totp.enabled). Send them to enrollment when they have not.
#The sign-in flow
POST /_auth/token behaves exactly as before for a realm with no second factor. When one is owed, it answers 401 with name: "MFA_REQUIRED" and the challenge in data instead of tokens:
1{ 2 "name": "MFA_REQUIRED", 3 "message": "A second authentication factor is required", 4 "data": { 5 "status": 401, 6 "challenge": "eyJzdWIiOi...", 7 "methods": ["totp"], 8 "sentTo": "a**@example.com" 9 }10}
The challenge is a signed, five-minute assertion that the password was verified. It grants nothing on its own. sentTo is a masked destination, present only for a factor whose code was sent somewhere.
POST /_auth/mfa with { challenge, code } mints the real session and answers with the same body a plain sign-in does, so a client only branches once.
POST /_auth/mfa/resend with { challenge } sends an out-of-band code again and answers { sentTo }. It is a no-op for TOTP, whose code never leaves the user's device. While the last code is still on its resend cooldown, no new one is sent and the code the user already has stays valid.
The email is sent inline, not queued: a code lives 300 seconds by default and a queued retry would arrive after it expired, so a failed send fails the sign-in in front of the user, who can try again.
#From React
1// inside a React component 2import { MfaRequiredError, useAuth } from "alepha/react/auth"; 3 4const { login, loginMfa } = useAuth(); 5 6try { 7 await login("credentials", { username, password }); 8} catch (error) { 9 if (error instanceof MfaRequiredError) {10 // error.data is typed: { challenge, methods, sentTo? }. Show a code field, then:11 await loginMfa(error.data.challenge, code);12 } else {13 throw error;14 }15}
MfaRequiredError is an UnauthorizedError.extend(...) (see Typed errors), so HttpClient rebuilds the class from the response body and instanceof holds in the browser. It replaces the isMfaRequired() helper, which is gone.
useAuth() also returns resendMfaCode(challenge), the client side of the resend route.
@alepha/ui's AuthLogin already does this: it swaps its form for the code step on a challenge, and offers a resend button when the method is emailCode. Applications with a hand-rolled login page use the calls above.
#Enrollment
MyMfaController exposes the self-service endpoints, all scoped to the caller. Every one but the status read requires a signed-in session: an API key or other machine credential is refused with a 403.
| Endpoint | Body | What it does |
|---|---|---|
GET /api/users/me/mfa |
{ totp: { enabled, pending, recoveryCodesLeft } } |
|
POST /api/users/me/mfa/totp/enroll |
Returns the secret, the otpauth:// URI, and a QR code as inline SVG |
|
POST /api/users/me/mfa/totp/activate |
{ code } |
Confirms a code and returns the recovery codes, once |
DELETE /api/users/me/mfa/totp |
{ code } |
Turns it off; the code is a current one or a recovery code |
POST /api/users/me/mfa/totp/recovery-codes |
{ code } |
Issues a fresh set, retiring the old one; same proof as turning it off |
Enrollment is refused with a 400 while mfa.totp is "disabled", and when TOTP is already active. Starting again on a pending enrollment (one never confirmed with a code) replaces it, so a user who scanned the wrong QR can simply restart. A pending enrollment gates nothing at sign-in.
The QR is rendered on the server, so an application does not need a QR encoder in its own bundle.
@alepha/ui's account security page has the whole dialog already. Nothing to build if you use it.
#What the authenticator app shows
The scanned entry is labelled <issuer>: <account>, and both come from the realm:
- issuer is
settings.displayName, falling back to the realm's internal name. - account is the user's email, or their username, or their id, in that order.
Set displayName. Without it the issuer is the realm name, which for every single-realm application is users, so the phone lists the entry as "users". That identifies nothing, and it collides with the next application that skips it too.
1realm = $realm({2 settings: {3 displayName: "Customer Portal", // ← what the phone will show4 mfa: { totp: "required", emailCode: "disabled" },5 },6});
An otpauth:// URI is consumed once, at scan time, so changing displayName later only affects new enrollments. Entries already on a phone keep the label they were created with.
#Why disabling asks for a code
A live session is not proof that the person at the keyboard still holds the second factor. Without the check, an unattended signed-in browser is enough to strip the factor off an account and come back later. Regenerating recovery codes asks for the same proof: new recovery codes are a way into the account, and a borrowed session must not be able to mint its own.
#Recovery codes
Activation returns ten single-use codes. Any of them is accepted wherever a TOTP code is (at sign-in, to disable, to regenerate), and each works exactly once. They are stored hashed, so that response is the only time they can ever be displayed, and a user who does not keep them has no way back in without an administrator.
An administrator resets a locked-out user from the "Two-factor authentication" card on that user's Security tab in the admin UI. It is deliberately its own card rather than a row among the connected accounts: an authenticator app is not a way to sign in, and removing it does not take a way in away, it takes a check away.
Underneath it is AdminIdentityController, deleting the user's totp identity row on the admin:identity:delete permission, and the deletion is recorded in the audit log.
#What is stored
Everything lives in the existing identities row, so turning this on needs no migration:
provider: "totp",providerUserId: nullproviderData: the secret (encrypted with the application secret), the status, the last accepted time step, and the hashed recovery codes
#Notes on the implementation
- Codes are RFC 6238, SHA-1, six digits, thirty-second steps, with one step of tolerance either side to absorb clock drift.
- A time step is single use. A code seen over someone's shoulder cannot be replayed inside its own validity window.
- The clock comes from
DateTimeProvider, so tests drive it withtravel(). - An emailed code is refused on its second use, even though the underlying verification record would still accept it: idempotency is right for confirming an address and wrong for a login factor.
- Second-factor attempts are rate limited on their own counter (
mfa), separate from password attempts, with the realm'sloginRateLimit.accountMaxAttemptsperwindowMs. A code is counted before it is compared and a passing code gives its slot back. The counter is actor state: per instance withoutREDIS_URL, shared across instances with it, a Durable Object on workerd. See the topology rule.