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 |