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. activeKind is the factor the challenge is asking for. Pass kind (totp, sms or recovery) when the user answers with a different one, such as a recovery code.
  • Try another way. POST /{appUid}/auth/mfa/resend with { challengeId, kind } switches the challenge to another factor, and texts a fresh code for sms.
  • 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. activeOrgUid on /auth/mfa/verify picks 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.

MethodPathDoes
GET/{appUid}/auth/mfaThe 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/recoveryIssues 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

CodeStatusMeaning
MFA_CODE_INVALID400The code did not match. Let the user try again.
LINK_TOKEN_INVALID404The challenge expired, was used, or ran out of attempts. Sign in again.
MFA_ALREADY_ENROLLED409A factor of this kind is already set up. Remove it before adding another.
MFA_NOT_ENROLLED400The account has no factor, for example when asking for recovery codes.
MFA_LAST_FACTOR409Pausing this factor would leave the account (or a required organization) unprotected.
MFA_ENROLLMENT_REQUIRED403The organization requires 2FA and the user has none. Route them to enrollment; never sign them out.

What's next

On this page