# Two-step Verification

> Manage two-step verification for a signed-in account: turn the switch on or off, enroll authenticator apps and passkeys, issue recovery codes, and rename or remove methods. Finish a pending sign-in or...

**URL:** https://inference.sh/docs/api/rest/second-factor
**Last updated:** 2026-10-31

---

Manage **two-step verification** for a signed-in account: turn the switch on or off, enroll **authenticator apps** and **passkeys**, issue **recovery codes**, and rename or remove methods. Finish a pending sign-in or re-authenticate a browser session through the shared verify endpoints (documented under [Login second factor](#login-second-factor) and [Magic link sign-in](/docs/api/rest/magic-link#login-second-factor)).

The workspace app exposes this under [Settings → Security](https://app.inference.sh/settings/security). Browser sign-ins that need a second factor redirect to [`/auth/verify`](https://app.inference.sh/auth/verify) with a `challenge_token` query parameter.

→ [Authentication](/docs/api/authentication) · [Magic link sign-in](/docs/api/rest/magic-link) · [Device authorization](/docs/api/rest/device-auth)

---

## When a second factor is required

| Account | At sign-in | Sensitive browser actions |
|---------|------------|---------------------------|
| **Platform staff** (`admin` role) | Always. Until an authenticator app or passkey is enrolled, only an **emailed code** is offered (no platform admin powers until an authenticator is proved). | Re-auth with an enrolled authenticator when a route requires it (`403 otp_required` or `403 admin_authenticator_required`). |
| **Members** | Only when **two-step verification** is turned on in settings. | Same re-auth rules when two-step verification is on. |

Two-step verification cannot be turned off for platform staff. Members need at least one enrolled **authenticator app** or **passkey** before they can turn the switch on.

---

## Authentication

| Endpoint group | Credential |
|----------------|------------|
| `GET /auth/second-factor` | Session cookie (read-only overview). |
| `POST /auth/second-factor/*`, TOTP enroll/confirm, passkey register | Session cookie **and** a **browser** session that has been **re-authenticated** within the last **4 hours** when the account requires a second factor. Changing methods without a fresh proof returns `403 otp_required`. |
| `GET /auth/otp/status`, `POST /auth/otp/verify`, `POST /auth/otp/resend`, `POST /auth/passkey/assert/begin` | Pending sign-in: `challenge_token`. Re-auth: session cookie. See [Login second factor](#login-second-factor). |

All settings routes require a normal signed-in session; API keys and CLI bearer tokens cannot manage two-step verification.

---

## Overview

`GET /auth/second-factor`

Returns the account's two-step verification state.

```json
{
  "enabled": true,
  "methods": [
    {
      "id": "sfm_abc",
      "kind": "passkey",
      "name": "MacBook Touch ID",
      "created_at": "2026-10-21T10:00:00Z",
      "last_used_at": "2026-10-21T12:00:00Z",
      "backup_state": true
    }
  ],
  "recovery_codes": {
    "generated_at": "2026-10-21T10:05:00Z",
    "remaining": 10
  },
  "passkeys_available": true
}
```

| Field | Description |
|-------|-------------|
| `enabled` | Whether two-step verification is on (`User.two_factor_enabled`). |
| `methods` | Enrolled authenticator apps (`kind: "totp"`) and passkeys (`kind: "passkey"`). |
| `recovery_codes` | Omitted until a set exists; `remaining` counts unused codes. |
| `passkeys_available` | Whether the server is configured as a WebAuthn relying party. |

### `UserDTO` fields

User objects in API responses (for example `user` on [`GET /me`](/docs/api/rest/teams#get-current-user)) expose the same switch:

| Field | Description |
|-------|-------------|
| `two_factor_enabled` | Whether two-step verification is on. **Use this field** in new integrations. |
| `totp_enabled` | **Deprecated.** Always equal to `two_factor_enabled`; kept for older clients. |

Method `kind` values used in API responses: `totp`, `passkey`. At verify time the API also accepts `email` (emailed code) and `recovery` (single-use recovery code).

---

## Turn two-step verification on or off

### Enable

`POST /auth/second-factor/enable`

Turns the switch on. Requires at least one enrolled authenticator app or passkey. When the account has no recovery codes yet, a new set of **10** codes is generated and returned **once**:

```json
{
  "enabled": true,
  "recovery_codes": ["abcd-efgh-ijkl-mnop", "..."]
}
```

Subsequent enable calls return `"recovery_codes": []` if codes already exist.

### Disable

`POST /auth/second-factor/disable`

Turns the switch off and **voids** recovery codes. Enrolled methods stay. Platform staff receive `403 admin_authenticator_required`.

Response: `{ "enabled": false }`.

---

## Recovery codes

`POST /auth/second-factor/recovery-codes`

Replaces the current set with **10** new single-use codes. The previous set stops working immediately. Requires re-authenticated browser session.

```json
{
  "codes": ["xxxx-yyyy", "..."],
  "generated_at": "2026-10-21T10:00:00Z"
}
```

Store codes securely. Each successful `POST /auth/otp/verify` with `"method": "recovery"` consumes one code.

---

## Manage methods

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/auth/second-factor/methods/{id}` | `PATCH` | Rename. Body: `{ "name": "Work laptop" }`. |
| `/auth/second-factor/methods/{id}` | `DELETE` | Remove the method. |

Rules enforced by the API:

- At most **10** methods per account (`method_limit`, 409).
- While two-step verification is **on**, at least one authenticator app or passkey must remain (`last_authenticator`, 409).
- Platform staff must always keep at least one authenticator app or passkey (`admin_authenticator_required`, 403).

---

## Enroll an authenticator app (TOTP)

Two-step enrollment uses a sealed **enrollment** token between enroll and confirm (expires in **10 minutes**). TOTP is not stored until confirm succeeds.

### Start enrollment

`POST /auth/totp/enroll`

```json
{
  "secret": "BASE32SECRET",
  "url": "otpauth://totp/...",
  "enrollment": "opaque-enrollment-token"
}
```

Scan `url` in an authenticator app (Google Authenticator, 1Password, and similar) or enter `secret` manually.

### Confirm

`POST /auth/totp/confirm`

```json
{
  "enrollment": "opaque-enrollment-token",
  "code": "123456",
  "name": "Authenticator app"
}
```

Returns a `SecondFactorMethodDTO` for the new method. Optional `name` defaults on the server when omitted.

---

## Enroll a passkey

Requires `passkeys_available: true` on the overview. Ceremonies expire in **5 minutes**.

### Begin registration

`POST /auth/passkey/register/begin`

```json
{
  "ceremony_id": "cer_abc",
  "options": { /* PublicKeyCredentialCreationOptions JSON */ }
}
```

Pass `options` to `navigator.credentials.create()` in the browser.

### Finish registration

`POST /auth/passkey/register/finish`

```json
{
  "ceremony_id": "cer_abc",
  "name": "MacBook Touch ID",
  "credential": { /* PublicKeyCredential.toJSON() */ }
}
```

Returns the enrolled method. When passkeys are not available (`passkeys_available: false`), begin and finish return `503 not_configured`.

---

## Login second factor

Used after passwordless sign-in (magic link, login code, OAuth, SAML) and for **re-auth** on an existing browser session. The same routes handle both cases.

| Mode | Credential |
|------|------------|
| **Pre-session** (login) | `challenge_token` from `AuthResponse` or the `/auth/verify` URL |
| **Re-auth** | Session cookie (omit `challenge_token`) |

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/auth/otp/status` | GET | `required`, `verified`, `methods` (default first), optional `reauth_until` on re-auth. |
| `/auth/otp/verify` | POST | Prove the second factor. See [Verify body](#verify-body). |
| `/auth/otp/resend` | POST | Send a new **emailed** code when `email` is among `methods`. |
| `/auth/passkey/assert/begin` | POST | Start passkey assertion for login or re-auth. Body: `{ "challenge_token": "…" }` or `{}` with session cookie. |

Login challenges expire after **10 minutes**. After a successful verify, the session is marked re-authenticated for **4 hours** (`reauth_until`).

Wrong codes share one **per-account** lockout: **10** failures within **15 minutes** returns `429 account_locked` (`second_factor_unavailable` when the lockout store is unavailable).

### Verify body

`POST /auth/otp/verify`

```json
{
  "challenge_token": "CHALLENGE_TOKEN",
  "method": "totp",
  "code": "123456"
}
```

| Field | Description |
|-------|-------------|
| `challenge_token` | Required for pre-session login; omit for re-auth. |
| `method` | One of `email`, `totp`, `passkey`, `recovery`. Defaults to the first entry in `GET /auth/otp/status` → `methods`. |
| `code` | Required for `email`, `totp`, and `recovery`. |
| `assertion` | Required for `passkey`: `{ "ceremony_id": "…", "credential": { … } }` from `assert/begin` + `navigator.credentials.get()`. |

Pre-session success returns `AuthResponse` with `session_id` and sets the session cookie. Re-auth success returns `{ "verified": true, "reauth_until": "…" }`.

Example: emailed code at login:

```bash
curl "https://api.inference.sh/auth/otp/status?challenge_token=CHALLENGE_TOKEN"

curl -X POST https://api.inference.sh/auth/otp/verify \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{"challenge_token": "CHALLENGE_TOKEN", "method": "email", "code": "123456"}'
```

Example: passkey at login:

```bash
# 1. Begin assertion
curl -X POST https://api.inference.sh/auth/passkey/assert/begin \
  -H "Content-Type: application/json" \
  -d '{"challenge_token": "CHALLENGE_TOKEN"}'

# 2. Run WebAuthn in the browser, then finish:
curl -X POST https://api.inference.sh/auth/otp/verify \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{"challenge_token": "CHALLENGE_TOKEN", "method": "passkey", "assertion": {"ceremony_id": "cer_abc", "credential": {}}}'
```

---

## Errors

### Settings and enrollment

| Code | HTTP | When |
|------|------|------|
| `last_authenticator` | 409 | Enable or remove would leave two-step on with no authenticator app or passkey |
| `admin_authenticator_required` | 403 | Platform staff cannot disable two-step or remove their last authenticator |
| `method_limit` | 409 | Ten methods already enrolled |
| `not_found` | 404 | Unknown method id |
| `expired` | 400 | Passkey ceremony or TOTP enrollment expired |
| `not_configured` | 503 | Passkeys not available on this server |
| `otp_required` | 403 | Settings change needs a fresh second-factor proof on the session |

### Verify step

| Code | HTTP | When |
|------|------|------|
| `invalid_code` | 400 | Wrong or consumed code |
| `account_locked` | 429 | Too many failed attempts within 15 minutes |
| `method_not_offered` | 400 | `method` not in `methods`, or resend when email is not offered |
| `missing_code` / `missing_assertion` | 400 | Required field missing for the chosen method |
| `invalid_token` | 400 | Expired or unknown `challenge_token` |
| `second_factor_unavailable` | 503 | Lockout store unavailable (fail closed) |

---

## Related

- [Magic link sign-in](/docs/api/rest/magic-link): Passwordless email login and `AuthResponse.otp_methods`
- [Authentication](/docs/api/authentication): API keys, sign-in flows, workspace context
- [API keys: admin scopes](/docs/api/rest/api-keys): `admin_authenticator_required` and platform power
