> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Two-factor authentication

> TOTP for staff accounts, with recovery codes and a setup that cannot lock anyone out.

Two-factor applies to **staff accounts** — the people who sign into the panel. It
is TOTP: an authenticator app, a 6-digit code, plus 8 single-use recovery codes.

<Note>
  When the server cannot keep a TOTP secret safely, every route here answers
  `501 two_factor_unavailable` and `GET /v1/session` reports
  `two_factor_available: false` — read that flag before showing the section, rather
  than hitting the `501` mid-flow.
</Note>

## Turning it on

Two calls, and the order is the point.

<Steps>
  <Step title="Start the setup">
    ```bash theme={null}
    curl -s -X POST $API/v1/account/two-factor/setup \
      -H "Authorization: Bearer $UK_SESSION"
    ```

    ```json theme={null}
    {
      "secret": "JBSWY3DPEHPK3PXP",
      "otpauth_url": "otpauth://totp/Userkit:ada@example.com?secret=…",
      "qr_data_uri": "data:image/png;base64,…"
    }
    ```

    Render `qr_data_uri` for scanning and show `secret` for anyone who cannot scan.
    This **does not turn two-factor on** — it only stores the secret, encrypted.
  </Step>

  <Step title="Enable it with a code">
    ```bash theme={null}
    curl -s -X POST $API/v1/account/two-factor/enable \
      -H "Authorization: Bearer $UK_SESSION" \
      -H 'Content-Type: application/json' \
      -d '{ "code": "123456" }'
    ```

    ```json theme={null}
    {
      "totp_enabled": true,
      "recovery_codes": ["a1b2c-3d4e5-f6a7b-8c9d0", "e5f6a-7b8c9-d0e1f-2a3b4", "…"]
    }
    ```
  </Step>
</Steps>

Splitting it in two is what keeps an abandoned setup from locking anyone out: the
secret exists but two-factor is off until the user proves they can produce a code
from it.

<Warning>
  `recovery_codes` is shown once and only here. Only hashes are stored. Tell the user
  to save them somewhere that is not the phone holding the authenticator.
</Warning>

Calling setup while two-factor is already on answers `409 already_enabled`.
Calling enable before setup answers `409 setup_required`.

## Signing in with it

Sign-in becomes two steps.

<Steps>
  <Step title="Password">
    ```bash theme={null}
    curl -s $API/v1/auth/login \
      -H 'Content-Type: application/json' \
      -d '{ "email": "ada@example.com", "password": "Analytical1" }'
    ```

    ```json 200 — no session yet theme={null}
    {
      "two_factor_required": true,
      "challenge_token": "uk_2fa_…"
    }
    ```

    Branch on `two_factor_required`. The challenge is valid for **5 minutes**, and it
    is not a session — it carries its own prefix precisely so it can never be used as
    one.
  </Step>

  <Step title="Code">
    ```bash theme={null}
    curl -s $API/v1/auth/two-factor \
      -H 'Content-Type: application/json' \
      -d '{ "challenge_token": "uk_2fa_…", "code": "123456" }'
    ```

    Answers a normal session — the same body as a login without two-factor.
  </Step>
</Steps>

A wrong code answers `401 invalid_code` and **does not consume the challenge**: fat
fingers on a 6-digit code are the common case. It does spend one of the
challenge's ten attempts, and a challenge that runs out answers
`invalid_challenge` from then on. The rate limit counts per address and an
attacker spreads guesses across addresses, so the budget carried by the
challenge itself is what actually bounds guessing. A correct code consumes the
challenge outright; it dies with the login it authorized.

## Recovery codes

Either a TOTP code or a recovery code satisfies the `code` field. A recovery code
is consumed on use, and using one sends a notice to the account's email — that
notice is how someone finds out a code they never used was used.

Regenerating replaces the whole set; the old ones stop working:

```bash theme={null}
curl -s -X POST $API/v1/account/two-factor/recovery-codes \
  -H "Authorization: Bearer $UK_SESSION" \
  -H 'Content-Type: application/json' \
  -d '{ "password": "Analytical1" }'
```

Never partial. A partially-known set is worse than a fresh one.

## Turning it off

```bash theme={null}
curl -s -X POST $API/v1/account/two-factor/disable \
  -H "Authorization: Bearer $UK_SESSION" \
  -H 'Content-Type: application/json' \
  -d '{ "password": "Analytical1" }'
```

The current password is required. A stolen session must not be enough to strip the
second factor off an account — that is the whole point of having one. A wrong
password answers `403 invalid_credentials`.

Disabling deletes the recovery codes with the secret, and mails the account.

## Notifications

Four moments send an email, and each exists so an action the owner did not take is
visible:

* two-factor enabled;
* two-factor disabled;
* a recovery code used to sign in;
* the password changed or reset.

## What this does not cover

Two-factor is for staff accounts. **Contacts** — your users — do not have it. Their
security surface is [hosted authentication](/en/customer-auth/hosted) or
[federated identity](/en/customer-auth/federated), where the second factor, if you
want one, is yours to run.
