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 and Magic link sign-in).
The workspace app exposes this under Settings → Security. Browser sign-ins that need a second factor redirect to /auth/verify with a challenge_token query parameter.
→ Authentication · Magic link sign-in · Device authorization
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. |
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.
1{2 "enabled": true,3 "methods": [4 {5 "id": "sfm_abc",6 "kind": "passkey",7 "name": "MacBook Touch ID",8 "created_at": "2026-10-21T10:00:00Z",9 "last_used_at": "2026-10-21T12:00:00Z",10 "backup_state": true11 }12 ],13 "recovery_codes": {14 "generated_at": "2026-10-21T10:05:00Z",15 "remaining": 1016 },17 "passkeys_available": true18}| 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) 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:
1{2 "enabled": true,3 "recovery_codes": ["abcd-efgh-ijkl-mnop", "..."]4}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.
1{2 "codes": ["xxxx-yyyy", "..."],3 "generated_at": "2026-10-21T10:00:00Z"4}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
1{2 "secret": "BASE32SECRET",3 "url": "otpauth://totp/...",4 "enrollment": "opaque-enrollment-token"5}Scan url in an authenticator app (Google Authenticator, 1Password, and similar) or enter secret manually.
Confirm
POST /auth/totp/confirm
1{2 "enrollment": "opaque-enrollment-token",3 "code": "123456",4 "name": "Authenticator app"5}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
1{2 "ceremony_id": "cer_abc",3 "options": { /* PublicKeyCredentialCreationOptions JSON */ }4}Pass options to navigator.credentials.create() in the browser.
Finish registration
POST /auth/passkey/register/finish
1{2 "ceremony_id": "cer_abc",3 "name": "MacBook Touch ID",4 "credential": { /* PublicKeyCredential.toJSON() */ }5}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. |
/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
1{2 "challenge_token": "CHALLENGE_TOKEN",3 "method": "totp",4 "code": "123456"5}| 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:
1curl "https://api.inference.sh/auth/otp/status?challenge_token=CHALLENGE_TOKEN"23curl -X POST https://api.inference.sh/auth/otp/verify \4 -H "Content-Type: application/json" \5 -c cookies.txt \6 -d '{"challenge_token": "CHALLENGE_TOKEN", "method": "email", "code": "123456"}'Example: passkey at login:
1# 1. Begin assertion2curl -X POST https://api.inference.sh/auth/passkey/assert/begin \3 -H "Content-Type: application/json" \4 -d '{"challenge_token": "CHALLENGE_TOKEN"}'56# 2. Run WebAuthn in the browser, then finish:7curl -X POST https://api.inference.sh/auth/otp/verify \8 -H "Content-Type: application/json" \9 -c cookies.txt \10 -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: Passwordless email login and
AuthResponse.otp_methods - Authentication: API keys, sign-in flows, workspace context
- API keys: admin scopes:
admin_authenticator_requiredand platform power