Two-factor authentication
Let your app's users add an authenticator app or a phone, answer the challenge at sign-in, and keep recovery codes and remembered devices safe.
Two-factor authentication (2FA) is a second proof owed after any first door succeeds: password, email code, magic link, Google or GitHub. A user can add an authenticator app (TOTP), a phone (SMS), or both, and gets ten single-use recovery codes with their first factor. Once a factor is on, no sign-in door returns a session until the challenge is answered.
Each user chooses this for their own account. An organization can also make it mandatory for its members; that is covered in Requiring two-factor.
Turning it on
Enrollment is self-service and acts only on the signed-in caller's own account.
Start enrollment
POST /{appUid}/auth/mfa/enroll. For an authenticator app, the response carries the secret and a
keyUri (an otpauth:// URI) to render as a QR code. They are returned once: a user who loses
them starts again. For SMS, send { "kind": "sms", "phone": "+14155550123" } (international
format); a code is texted and the response carries an enrollmentId.
Confirm with a code
POST /{appUid}/auth/mfa/confirm with the six-digit code proves the factor works and switches it
on (for SMS, send the enrollmentId with it). On the account's first factor the response includes
recoveryCodes: show them to the user now, because the server keeps only hashes and can never
show them again.
# 1. Start: returns { kind, secret, keyUri }
curl -X POST "$AUTHORIC_URL/$APP_UID/auth/mfa/enroll" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "kind": "totp" }'
# 2. Confirm with the code from the authenticator app
curl -X POST "$AUTHORIC_URL/$APP_UID/auth/mfa/confirm" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "kind": "totp", "code": "482913" }'Confirming a factor ends every other session the account has. The device that made the change stays signed in. People turn on 2FA most often because they suspect someone else is in the account, and a change that left that someone signed in would do nothing about them.
SMS costs money; the authenticator app does not
An authenticator app works on every plan. SMS is available when the deployment has an SMS provider configured and your plan includes it, and every text counts against your plan's SMS allowance (see Your plan). If the allowance runs out, a user who also has another factor or recovery codes is offered those instead: running out of texts never locks a user out.
Answering the challenge at sign-in
When an account has a factor, every sign-in door answers 200 with a challenge instead of a
session. It deliberately has no auth key, so a client that ignores it fails loudly rather than
half-signing-in.
{
"mfaRequired": true,
"challengeId": "f3a9...c21",
"activeKind": "totp",
"factors": [
{ "kind": "totp", "label": null, "phoneLast4": null },
{ "kind": "sms", "label": null, "phoneLast4": "0123" }
],
"recoveryAvailable": true,
"delivered": false,
"sentTo": null,
"smsUnavailableReason": null
}Post the code to POST /{appUid}/auth/mfa/verify. It returns the ordinary session payload.
curl -X POST "$AUTHORIC_URL/$APP_UID/auth/mfa/verify" \
-H "Content-Type: application/json" \
-d '{ "challengeId": "<challengeId>", "code": "482913", "rememberDevice": true }'- Which factor.
activeKindis the factor the challenge is asking for. Passkind(totp,smsorrecovery) when the user answers with a different one, such as a recovery code. - Try another way.
POST /{appUid}/auth/mfa/resendwith{ challengeId, kind }switches the challenge to another factor, and texts a fresh code forsms. - Limits. A challenge lasts five minutes, works once, and allows a fixed number of attempts. A TOTP code is refused the second time it is used, even inside its 30-second window.
- Organization.
activeOrgUidon/auth/mfa/verifypicks the organization to sign into, as on any sign-in door.
Remembered devices
With rememberDevice: true, a successful verify returns trustedDevice: { token, trustDays }.
Store the token on that device and send it as deviceToken on any later sign-in
(/auth/login, the email-code and magic-link verifies, the OAuth exchange). The challenge is then
skipped on that device until it expires. The user still needs their first factor every time.
The token is a credential that walks past 2FA, so it is treated like one:
- it is bound to one account and stored only as a hash;
- it lasts 30 days by default, at most 90, and an organization can shorten it or forbid it
(
0), which also forgets the devices already remembered; - an account holds at most ten; the oldest is dropped;
- it survives the user's own sign-out, but not a security action: signing a session out from the session list forgets that session's device, "sign out everywhere else" forgets all but the current one, and a password reset or removing the last factor forgets them all.
A user can forget devices themselves: DELETE /{appUid}/auth/mfa/devices/{uid} for one,
DELETE /{appUid}/auth/mfa/devices for all. GET /{appUid}/auth/mfa lists them alongside the
factors.
Managing factors
All of these act on the signed-in caller's own account.
| Method | Path | Does |
|---|---|---|
GET | /{appUid}/auth/mfa | The account's factors, recovery codes remaining, and remembered devices |
PATCH | /{appUid}/auth/mfa/{kind} | { "enabled": false } pauses a factor and keeps its setup, so resuming needs no new QR scan or phone check |
DELETE | /{appUid}/auth/mfa/{kind} | Removes a factor. Removing the last one also deletes the recovery codes and every remembered device, and ends the account's other sessions |
POST | /{appUid}/auth/mfa/recovery | Issues a fresh set of ten recovery codes, invalidating the old set |
Pausing can never be what turns 2FA off: it is refused while it would leave no working factor, or
none that satisfies an organization requiring 2FA (MFA_LAST_FACTOR). Removing is allowed, because
it says what it does, and the user can enroll again at any time.
Errors to branch on
| Code | Status | Meaning |
|---|---|---|
MFA_CODE_INVALID | 400 | The code did not match. Let the user try again. |
LINK_TOKEN_INVALID | 404 | The challenge expired, was used, or ran out of attempts. Sign in again. |
MFA_ALREADY_ENROLLED | 409 | A factor of this kind is already set up. Remove it before adding another. |
MFA_NOT_ENROLLED | 400 | The account has no factor, for example when asking for recovery codes. |
MFA_LAST_FACTOR | 409 | Pausing this factor would leave the account (or a required organization) unprotected. |
MFA_ENROLLMENT_REQUIRED | 403 | The organization requires 2FA and the user has none. Route them to enrollment; never sign them out. |