# Two-step sign-in

Ask the people who sign in to your app for a code from their phone as well, so a stolen password alone is not enough.

Two-step sign-in asks for a second proof after the first one. Someone signs in with their password, an email link or another method as usual, then types a short code from their phone. A password can leak, be guessed or be reused on a site that was hacked. With two-step sign-in, whoever has the password still cannot get in without the person's phone. You may also know it as two-factor authentication, 2FA or MFA.

It applies to the people who sign in to your app, not to your team in the BuildBase console. It runs on your hosted sign-in page, so signing in needs nothing new in your app.

Step by step, with screenshots: [Set up two-step sign-in](/sign-in-setup/two-step).

> **Note:**
  Choose the policy under **User Management → Access → Authentication →
  Security**, in the **Two-step sign-in** card. People turn it on for themselves
  from the Security screen of your app's settings.


## Choose who is asked

| Policy             | What your users see                                                                                                                                                                                                              |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Off                | Nobody is asked for a code, and your app's Security screen offers nothing to set up. What people set up before is kept, and they are asked for codes again if you turn the policy back on.                                       |
| Optional (default) | Each person chooses. Whoever turned it on is asked for a code when they sign in, apart from the cases in [Who is never asked](#who-is-never-asked) and on [devices they trust](#trusted-devices). Nobody else sees anything new. |
| Required           | Everyone is asked, apart from those same cases. Someone with no second step yet sets up an authenticator app on the sign-in page, at their next sign-in, before they reach your app. A new sign-up does it too.                  |

Saving **Required** asks you to confirm first. Nobody already signed in is signed out: the policy is read at each sign-in, so it takes effect from each person's next one. While it is **Required**, people cannot remove their authenticator app, and cannot turn off text message codes when those are their only second step.

**Also ask social sign-ins**, in the same card, is off by default, because Google, GitHub and the other providers run their own checks. Turn it on to ask for the code after a social sign-in too, Google One Tap included.

## The second steps

### Authenticator app

An authenticator app is an app on the person's phone, such as Google Authenticator, Microsoft Authenticator, 1Password or Authy. It shows a new 6-digit code every 30 seconds, and works without signal. The person sets it up once, by scanning a QR code with the app or typing in a key. The entry in their app carries your app's name: the **Name** on your sign-in page, or your organization's name.

Each code works once. A code from just before or just after the current one is accepted too, in case the phone's clock is a little off. The authenticator app is always allowed: it is the stronger choice, and the card does not let you save a list without it.

### Backup codes

Setting up an authenticator app gives the person 10 backup codes, shown once. They are spare codes for the day the phone is lost, and they look like `k7m2p-x9rqa`: ten letters and digits, without the ones that are easy to confuse, such as `0` and `o`. Capitals, spaces and the dash do not matter when typing one.

- **Each code works once**, in place of a code from the app.
- **The hosted page counts down.** After a backup code it says when 3 or fewer are left, and when the last one is gone.
- **New backup codes** come from the Security screen of your app, after the person types a code from the app or a backup code. The old set stops working at once.
- **Text messages come without backup codes.** Only the authenticator app has them.

### Text message codes

Tick **Text message codes** in the card to let people get their code by text message instead. It is the weaker choice: a phone number can be moved to another SIM card by someone who talks a phone company into it, and a text can be intercepted. Offer it for people who would otherwise have no second step at all.

- **Never the only one.** The authenticator app stays allowed beside it. Saving a list of text messages alone is refused with `sms_only_factor`.
- **It needs your own SMS provider**, connected on the **SMS** tab, and a plan that includes phone sign-in codes. Each text is billed by your provider and counts toward your daily cap and the [per-number limits](/authentication/phone#limits). See [Phone sign-in](/authentication/phone).
- **People turn it on** from the Security screen of your app, with a verified phone number on their account. The same screen's **Phone number** section adds one. The sign-in page's own set-up offers only the authenticator app.
- **At sign-in**, the page offers "Text a code to" the person's masked number, marked as the less secure choice, and sends nothing until they ask. A new code can be sent after 30 seconds.
- **Never after a phone code.** Someone who signed in with a code sent to their phone is not offered a text message as the second step: the same phone would be both steps.
- **If you untick it or disconnect your provider**, people whose only second step is text messages stop being asked for one under **Optional**. Under **Required**, they set up an authenticator app at their next sign-in.

A person cannot remove the phone number their text messages go to until they turn text message codes off.

### Passkeys

A passkey sign-in where the device checks the person, with a fingerprint, a face or the screen lock PIN, already counts as both steps, so no code is asked for. A passkey sign-in without that check is asked for a code like a password sign-in. A passkey is not offered as the second step after another way of signing in.

## Who is never asked

- **Passkey sign-ins** that checked the person, as above.
- **Social sign-ins**, unless **Also ask social sign-ins** is on.
- **Agent accounts**, made through agent sign-up, under every policy. A program has nobody to read a code.
- **[Guests](/authentication/guests).** A guest has no address to set a second step up against. It starts once they create an account.
- **You, signing in as the person** from the console. [Signing in as a user](/authentication/sign-in-as) makes its session without their code.

Everyone else is asked whenever the policy says so: after a password, an email link or code, a phone code, a wallet signature, or a [device code](/authentication/device-code) approval.

## Trusted devices

After someone signs in on a device they have not trusted yet, the sign-in page asks **Trust this device?**. Saying yes keeps them signed in there for the **Trust a device for** window and, when that sign-in included a code, skips the code on that device the next times they sign in.

Both settings are in the **Security & sessions** card on the same tab:

| Setting                  | Default | What it does                                                                                                                                                                                                        |
| ------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Ask to trust new devices | On      | Shows the question. Off, nobody is asked, and every device asks for the code again, including devices trusted before.                                                                                               |
| Trust a device for       | 90 days | How long a trusted device stays signed in and skips the code, from 1 to 180 days. Without trust, a sign-in lasts the session lifetime, 30 days by default. |

A trusted device asks for the code again when:

- the **Trust a device for** window has run out
- the person turned on a second step after their last code there, such as text messages beside the app
- the person removed their authenticator app, or you reset their two-step sign-in
- your app asks for the second step on purpose, with [Confirm it's you](/authentication/reverification) and `factors: ['mfa']`

The page tells people to trust only a device that is theirs, never a shared or public computer.

## What your users do

### Turning it on in your app

The Security screen of the SDK's settings has a **Two-step sign-in** section. Open it from your own button:

```tsx
import { useSaaSAuth } from '@buildbase/sdk/react';

function SecurityButton() {
  const { user, openWorkspaceSettings } = useSaaSAuth();
  return (
    <button onClick={() => openWorkspaceSettings('security')}>
      {user?.mfaEnabled ? 'Security settings' : 'Turn on two-step sign-in'}
    </button>
  );
}
```

1. Next to **Authenticator app**, press **Set up**.
2. Scan the QR code with the authenticator app, or type in the key shown under it.
3. Type the 6-digit code the app shows and press **Turn on**.
4. Save the backup codes with **Copy**, in a password manager or on paper, then press **Done**. They are not shown again.

For text messages, the person first adds and verifies a phone number in the **Phone number** section of the same screen, then presses **Turn on** beside **Text message** and types the code that arrives. Turning text messages off asks them to have signed in within the last 10 minutes, or to confirm it's them first.

The section hides itself while your policy is **Off** and nothing is set up. Hide it yourself with `ui.settings.security.twoStep: false` on `SaaSOSProvider`.

### Setting it up at sign-in

Under **Required**, someone with no second step sees **Set up two-step sign-in** after their first step. They scan the QR code with an authenticator app, type its code, then save their backup codes with **Copy** or **Download .txt** and press **I've saved these codes** to carry on into your app.

### Signing in with a code

After the first step, the **Two-step sign-in** page asks for the 6-digit code from the app. Links under it switch to **Use a backup code** or, for someone with text message codes, **Text a code to** their number.

- The second step has to be finished within 5 minutes of the first.
- After 5 wrong codes, that sign-in ends and the person starts again.
- After 10 wrong codes within 15 minutes, across any number of sign-ins, the account takes no more codes until 15 minutes after the first of them, and a new sign-in stops at the first step with "Too many incorrect codes. Please wait up to 15 minutes and try again." Removing the app and making new backup codes count toward the same limit.

### A lost phone

1. **With a backup code**, the person signs in with it, then sets up the app on their new phone from the Security screen: **Turn off**, with another backup code, then **Set up**. Under **Required**, **Turn off** is not offered, so they ask you for a reset instead.
2. **Without backup codes**, they ask you. Make sure the person is who they say they are, then open them under **Users**, press **More** and choose **Reset two-step sign-in**.

A reset removes the authenticator app, the backup codes and text message codes. Every trusted device asks for a code again, and a sign-in already in progress has to start again. It does not sign the person out of your app: end their sessions from the **Devices** tab if you need to. Under **Required**, they set up a new app at their next sign-in. Resetting needs a console role that can both update and delete users.

The two-step page tells someone who lost everything to contact your support team, with your support link if your sign-in page has one.

## For developers

### Your own screens

The Security screen's calls are on `WorkspaceApi`. Their routes are under `/api/v1/public`, authorized by the session:

```tsx
import { useMemo } from 'react';
import { WorkspaceApi } from '@buildbase/sdk';
import { useSaaSOs } from '@buildbase/sdk/react';

export function useWorkspaceApi() {
  const os = useSaaSOs();
  return useMemo(
    () =>
      new WorkspaceApi({
        serverUrl: os.serverUrl,
        version: os.version,
        orgId: os.orgId,
      }),
    [os.serverUrl, os.version, os.orgId]
  );
}

// In a component: const api = useWorkspaceApi();
// then, in an effect or a click handler:
const status = await api.getMfaStatus();
// { enabled, enforcement, allowedFactors, backupCodesRemaining?, sms?: { allowed, enrolled, phone? } }
```

| Call                          | Route                               | What it does                                                                   |
| ----------------------------- | ----------------------------------- | ------------------------------------------------------------------------------ |
| `getMfaStatus()`              | `GET /mfa`                          | The person's second steps and your policy                                      |
| `startTotpEnrollment()`       | `POST /mfa/totp/enroll`             | A new key, its `otpauth://` URL and a QR code as SVG. Show the SVG as an image |
| `confirmTotpEnrollment(code)` | `POST /mfa/totp/confirm`            | Turns the app on with its first code, and answers the backup codes, this once  |
| `regenerateBackupCodes(code)` | `POST /mfa/backup-codes/regenerate` | New backup codes for a current code or backup code. The old ones stop working  |
| `disableTotp(code)`           | `POST /mfa/totp/disable`            | Removes the app, for a current code or backup code                             |
| `startSmsEnrollment()`        | `POST /mfa/sms/enroll`              | Texts a code to the verified phone. Answers `retryAfter`                       |
| `confirmSmsEnrollment(code)`  | `POST /mfa/sms/enroll/confirm`      | Turns text message codes on                                                    |
| `disableSms()`                | `POST /mfa/sms/disable`             | Turns them off. Needs a sign-in within the last 10 minutes                     |

A refusal keeps the server's sentence as its message and carries the reason as `code`. None of them signs the person out.

| `code`               | Status | When                                                                                                    |
| -------------------- | ------ | ------------------------------------------------------------------------------------------------------- |
| `mfa-off`            | 403    | Your policy is **Off**, so nothing new can be set up                                                    |
| `already-enrolled`   | 409    | An authenticator app is already on. Remove it first                                                     |
| `not-enrolling`      | 409    | No set-up is waiting to be confirmed. Start again                                                       |
| `not-enrolled`       | 404    | Nothing to remove or replace                                                                            |
| `invalid-code`       | 400    | The app code or backup code is wrong                                                                    |
| `required-by-org`    | 403    | Your policy is **Required**: the app cannot be removed, nor text messages that are the only second step |
| `locked`             | 429    | 10 wrong codes in 15 minutes                                                                            |
| `factor_not_allowed` | 403    | Text message codes are not allowed, or no provider is connected                                         |
| `phone_required`     | 409    | The account has no verified phone number                                                                |
| `invalid_code`       | 400    | The texted code is wrong                                                                                |
| `code_expired`       | 410    | The texted code has lapsed. Send a new one                                                              |
| `sms_unavailable`    | 503    | Your SMS provider cannot send or check codes right now                                                  |
| `reauth_required`    | 403    | Turning text messages off, more than 10 minutes after signing in                                        |

Sending a text can also be refused with the [phone sign-in send refusals](/authentication/phone#if-you-call-the-auth-api-yourself), such as `cooldown` with `retryAfter`.

`useSaaSAuth().user.mfaEnabled` is `true` while the person has either second step on.

### What the token says

The session's `amr` lists how the person signed in, as RFC 8176 values. A code at sign-in adds `otp` and `mfa` to the first step's value, so a password and an app code give `['pwd', 'otp', 'mfa']`. A backup code gives the same. A texted code adds `sms`, `otp` and `mfa`. A trusted device and a passkey that counted as both steps skip the code, so their `amr` has no `mfa`. Read it as `useSaaSAuth().session.amr`; see [How recently the session signed in](/authentication/sessions-and-tokens#how-recently-the-session-signed-in).

### If you call the auth API yourself

The hosted page does this for you. When a second step is needed, the first step answers with this instead of a token:

```json
{
  "success": true,
  "mfaRequired": true,
  "mfaToken": "<40 characters>",
  "factors": [
    { "type": "totp", "backupCodesRemaining": 10 },
    { "type": "sms", "backupCodesRemaining": 0, "phone": "+1 ••• ••• 0123" }
  ],
  "enrollRequired": false
}
```

With `enrollRequired: true`, `factors` is empty and the person sets up an app first. The step after the first, `POST /api/v1/auth/redirect`, can answer `403` with `reason: "mfa_required"` and the same fields, for a sign-in that has not passed the second step your policy now asks for, such as a new sign-up under **Required**.

| Route                                       | Body                                               | Answers                                                                               |
| ------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `POST /api/v1/auth/mfa/verify`              | `mfaToken`, `code`, and `factor: "sms"` for a text | `{ success, data: { user, token } }`, with `backupCodesRemaining` after a backup code |
| `POST /api/v1/auth/mfa/sms/send`            | `mfaToken`                                         | `{ success, retryAfter: 30 }`                                                         |
| `POST /api/v1/auth/mfa/totp/enroll`         | `mfaToken`                                         | The key, URL and QR code, as `startTotpEnrollment` does                               |
| `POST /api/v1/auth/mfa/totp/enroll/confirm` | `mfaToken`, `code`                                 | The token, with `backupCodes`, this once                                              |

| Refusal                    | When                                                                             |
| -------------------------- | -------------------------------------------------------------------------------- |
| `400` `invalid_code`       | The code is wrong. The message says which kind it took the code for              |
| `401` `mfa_expired`        | The 5 minutes have passed, the sign-in had 5 wrong codes, or it already finished |
| `429` `mfa_locked`         | 10 wrong codes on the account within 15 minutes                                  |
| `400` `enroll_required`    | `/verify` while the person has to set up an app first                            |
| `400` `challenge_required` | `/totp/enroll` or its confirm while the person already has a second step         |
| `400` `factor_unavailable` | A text message is not offered for this sign-in                                   |
| `400` `code_expired`       | The texted code has lapsed                                                       |
| `503` `sms_unavailable`    | Your SMS provider cannot send or check the code right now                        |
| `403` `blocked`            | The account is blocked                                                           |

The text send can also answer `429` `cooldown` and `rate_limited`, and `422` `number_refused`, as [phone sign-in](/authentication/phone#if-you-call-the-auth-api-yourself) does.

### Events

| Event                     | When                                                                                                |
| ------------------------- | --------------------------------------------------------------------------------------------------- |
| `user.mfa_enrolled`       | A person turns on an authenticator app or text messages; `factor` says which                        |
| `user.mfa_disabled`       | A person turns one off                                                                              |
| `user.mfa_reset_by_admin` | You reset a person's two-step sign-in; `factors` lists what was removed                             |
| `auth.login_failed`       | A wrong code, with `stage: "second_factor"`, and `locked: true` once the account stops taking codes |

Subscribe to any of them as a [webhook event](/webhooks/overview).

## Next steps

- [Phone sign-in](/authentication/phone) - connect the SMS provider text message codes go through.
- [Confirm it's you](/authentication/reverification) - ask for the second step again before a sensitive change.
- [Sessions & Tokens](/authentication/sessions-and-tokens) - session lifetimes, devices and `amr`.
- [Signing in as a user](/authentication/sign-in-as) - see your app as one of your users, without their code.
