> ## 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

> TOTP for your customers, enforced on every way in — including the social button.

Two-factor for contacts is the same machinery the panel gives your staff, one
layer down: an authenticator app, single-use recovery codes, and a short-lived
challenge between the two steps.

<Note>
  It is free, and it always will be. No authentication method sits behind a
  paid plan here — charging for the second factor makes the free tier a trap and
  security an upsell.
</Note>

## The decision worth knowing about

**It is enforced wherever a session is minted.** Password sign-in, magic link,
email code, the verification link and social sign-in all stop at the same
challenge.

A second factor you can route around by clicking "continue with Google" is a
checkbox, not a control — and the person who turned it on has no way of knowing
which doors it actually covers. So it covers all of them.

<Warning>
  A password reset does **not** clear it. Letting it would hand the bypass to
  whoever holds the inbox, which is most of what two-factor exists to stop.

  The consequence is real and you should plan for it: a contact who loses both
  their phone and their recovery codes cannot get back in on their own. There is
  no tenant-side override yet — see [what is missing](#what-is-missing).
</Warning>

## Enrolling

Two calls, from a signed-in contact session.

<Steps>
  <Step title="Setup">
    ```bash theme={null}
    curl -s -X POST $API/v1/contact/two-factor/setup \
      -H "Authorization: Bearer uk_ct_…"
    ```

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

    Render the QR; show the secret for anyone who cannot scan. This does **not**
    turn two-factor on.
  </Step>

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

    ```json 200 theme={null}
    { "totp_enabled": true, "recovery_codes": ["a1b2c-3d4e5-f6a7b-8c9d0", "…"] }
    ```

    Only now is it on. Waiting for a code that works means an abandoned setup —
    the tab closed halfway, the wrong app scanned — never locks anyone out.
  </Step>
</Steps>

The recovery codes are shown **once**. Show them once too, and say what they
are for.

## Signing in

Whichever door they came through, a contact with two-factor on gets a challenge
instead of a session:

```json 200 theme={null}
{ "two_factor_required": true, "challenge_token": "uk_c2_…" }
```

That token is not a session — it carries its own prefix precisely so it can
never be spent as one — and it lives ten minutes.

```bash theme={null}
curl -s $API/v1/contact-auth/two-factor \
  -H 'Content-Type: application/json' \
  -d '{
    "publishable_key": "uk_pk_live_…",
    "challenge_token": "uk_c2_…",
    "code": "123456"
  }'
```

The response is the ordinary sign-in response: contact, `uk_ct_…` token,
expiry, `verified`. That last field carries whatever the *first* factor earned
— a magic link and a social callback both prove the identity before the code is
asked for, and that proof rides on the challenge rather than being recomputed
after it.

A recovery code goes in the same `code` field. It is spent when used, and a
notice goes to the inbox — somebody using a backup code is worth noticing.

A wrong code does **not** consume the challenge; fat fingers on six digits 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, whatever code
arrives — the rate limit counts per address, and an attacker spreads guesses
across addresses, so the budget is what actually bounds guessing a six-digit
number. Ten is well above what a person mistypes; a person who hits it starts a
new sign-in.

## Turning it off

```bash theme={null}
curl -s -X POST $API/v1/contact/two-factor/disable \
  -H "Authorization: Bearer uk_ct_…" \
  -H 'Content-Type: application/json' \
  -d '{ "code": "123456" }'
```

A code that currently works, TOTP or recovery. Not the password: a social-only
contact has none, and the right proof for removing the second factor is the
second factor.

`POST /v1/contact/two-factor/recovery-codes` replaces the whole set the same
way. Every previous code stops working — a partially-known set is worse than a
fresh one.

## Hosted mode only

Enrolling in a **federated** environment answers `409 environment_not_hosted`.
There, your own server authenticates and `boot` asserts the result: there is no
moment we could interrupt to ask for a code. Two-factor is ours to enforce only
where the sign-in is ours to run — if your product has its own MFA, that is
where it belongs.

## What is missing

There is no way for you to lift two-factor off a contact who lost both their
phone and their recovery codes. That is deliberate rather than forgotten: the
alternative on the table was letting a password reset clear it, which hands the
bypass to whoever holds the inbox.

It belongs with the support surface, audited — not with auth. Until it lands,
the honest thing to tell your customers is to keep the recovery codes.
