//TOTP MFA

TOTP Multi-Factor Authentication

Time-based One-Time Password (TOTP) MFA adds a second factor to user logins. Factor-management endpoints require Authorization: Bearer <jwt> and are scoped to the calling user. The login-time challenge is the one exception — it is reached before a full session exists and is authorised by the short-lived mfa_token the login endpoint returns.

Endpoint Summary

| Method | Path | Auth | Purpose | |---|---|---|---| | GET | /api/v1/me/mfa/factors | Bearer | List the user's enrolled factors | | POST | /api/v1/me/mfa/factors | Bearer | Enroll a factor (TOTP secret is minted server-side) | | POST | /api/v1/me/mfa/factors/{id}/verify | Bearer | Activate a pending factor with its first code | | DELETE | /api/v1/me/mfa/factors/{id} | Bearer | Disable a factor | | GET | /api/v1/me/mfa/recovery-codes | Bearer | List recovery-code metadata (no plaintext) | | POST | /api/v1/me/mfa/recovery-codes/regenerate | Bearer | Replace recovery codes (plaintext returned once) | | GET | /api/auth/mfa/status | Bearer | MFA enrollment summary | | POST | /api/auth/mfa/totp/challenge | mfa_token | Complete a login that stopped at the MFA gate |

Endpoints

GET /api/auth/mfa/status

Returns the user's MFA enrollment summary. (Also available at /api/v1/me/mfa/status.)

Output: ~~~json { "has_active_totp": true, "has_recovery_codes": true } ~~~

POST /api/v1/me/mfa/factors

Enrolls a new factor. For factor_type: "totp" the server mints a base32 secret and returns it (once) along with a ready-to-scan otpauth_uri. The factor starts in pending status until verified. label is optional.

Input: ~~~json { "factor_type": "totp", "label": "Pixel 8" } ~~~

Output: ~~~json { "data": { "id": 42, "user_id": 7, "factor_type": "totp", "secret": "JBSWY3DPEHPK3PXP", "otpauth_uri": "otpauth://totp/Buttrbase:Pixel%208?secret=JBSWY3DPEHPK3PXP&issuer=Buttrbase", "label": "Pixel 8", "status": "pending", "created_at": "2026-06-07T10:15:00Z" } } ~~~

POST /api/v1/me/mfa/factors/{id}/verify

Activates a pending factor by proving control of it. For TOTP the server validates code against the stored secret before flipping the factor to active.

Input: ~~~json { "code": "123456" } ~~~

Output: ~~~json { "data": { "id": 42, "factor_type": "totp", "status": "active", "activated_at": "2026-06-07T10:16:00Z" } } ~~~

GET /api/v1/me/mfa/factors

Lists the user's factors. Secrets are never included here — only the create response surfaces a secret.

Output: ~~~json { "data": [ { "id": 42, "factor_type": "totp", "label": "Pixel 8", "status": "active" } ] } ~~~

DELETE /api/v1/me/mfa/factors/{id}

Disables (soft-deletes) a factor. Responds 204 No Content.

POST /api/v1/me/mfa/recovery-codes/regenerate

Invalidates any existing recovery codes and issues 10 fresh single-use codes. The plaintext codes are returned exactly once — surface them to the user immediately.

Output: ~~~json { "data": { "codes": ["k3n8fp2q9d", "7xa1mz04rt", "..."] } } ~~~

GET /api/v1/me/mfa/recovery-codes

Lists recovery-code metadata (id, created/used timestamps). Plaintext and hashes are never returned.

Output: ~~~json { "data": [ { "id": 1, "used_at": null, "created_at": "2026-06-07T10:16:00Z" } ] } ~~~

POST /api/auth/mfa/totp/challenge

Completes a login that returned mfa_required. Pass the short-lived mfa_token from the login response plus a TOTP code, or set recovery: true and pass a single-use recovery code. On success the full access/refresh pair is returned — identical to a factor-less login. No Authorization header is required; the mfa_token authorises the call.

Input: ~~~json { "mfa_token": "eyJ0...", "code": "123456" } ~~~

Input (recovery code): ~~~json { "mfa_token": "eyJ0...", "code": "k3n8fp2q9d", "recovery": true } ~~~

Output: ~~~json { "access_token": "eyJ0...", "refresh_token": "eyJ0...", "token_type": "Bearer", "expires_in": 420 } ~~~

Worked Example (cURL)

~~~bash

1. Enroll a TOTP factor — the server mints the secret.

curl -X POST https://api.buttrbase.com/api/v1/me/mfa/factors \ -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"factor_type":"totp","label":"Pixel 8"}'

→ { "data": { "id": 42, "secret": "JBSWY3DPEHPK3PXP",

"otpauth_uri": "otpauth://totp/Buttrbase:Pixel%208?secret=...", "status": "pending" } }

Render otpauth_uri as a QR for the authenticator app.

2. Activate it with the first code from the app.

curl -X POST https://api.buttrbase.com/api/v1/me/mfa/factors/42/verify \ -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"code":"123456"}'

3. Generate recovery codes (shown once — store them).

curl -X POST https://api.buttrbase.com/api/v1/me/mfa/recovery-codes/regenerate \ -H "Authorization: Bearer $JWT"

→ { "data": { "codes": ["k3n8fp2q9d", "7xa1mz04rt", ...] } }

4. On a later login the password call returns an MFA challenge:

{ "mfa_required": true, "mfa_token": "eyJ0..." }

Complete it with a TOTP code:

curl -X POST https://api.buttrbase.com/api/auth/mfa/totp/challenge \ -H "Content-Type: application/json" \ -d '{"mfa_token":"eyJ0...","code":"123456"}'

→ { "access_token": "eyJ0...", "refresh_token": "eyJ0...", "token_type": "Bearer" }

4b. Lost the authenticator? Use a single-use recovery code instead:

curl -X POST https://api.buttrbase.com/api/auth/mfa/totp/challenge \ -H "Content-Type: application/json" \ -d '{"mfa_token":"eyJ0...","code":"k3n8fp2q9d","recovery":true}'

5. Disable a factor.

curl -X DELETE https://api.buttrbase.com/api/v1/me/mfa/factors/42 \ -H "Authorization: Bearer $JWT" ~~~

Typical Flow

1. Enroll: POST /api/v1/me/mfa/factors → render the returned otpauth_uri as a QR → user scans → POST /api/v1/me/mfa/factors/{id}/verify with the first code to activate. 2. Login: when the user has an active factor, POST /api/auth/login returns { mfa_required: true, mfa_token } instead of tokens. Complete the login with POST /api/auth/mfa/totp/challenge (mfa_token + code). 3. Recovery: if the user loses their authenticator, call the challenge with recovery: true and a code from /recovery-codes/regenerate.

Error Reference

| Status | Code | Meaning | |--------|------|---------| | 401 | unauthorized | TOTP/recovery code incorrect, or the mfa_token is invalid/expired | | 400 | bad_request | Malformed body (e.g. missing factor_type or code) | | 404 | not_found | Factor not found, or already active when verifying | | 500 | internal | Stored secret unreadable or signing failure |