POST
/
api
/
v1
/
admin
/
auth
/
mfa-recovery
Admin MFA recovery (lost device)
curl --request POST \
  --url https://api.example.com/api/v1/admin/auth/mfa-recovery
import requests

url = "https://api.example.com/api/v1/admin/auth/mfa-recovery"

response = requests.post(url)

print(response.text)
const options = {method: 'POST'};

fetch('https://api.example.com/api/v1/admin/auth/mfa-recovery', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));
<?php

$curl = curl_init();

curl_setopt_array($curl, [
CURLOPT_URL => "https://api.example.com/api/v1/admin/auth/mfa-recovery",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
]);

$response = curl_exec($curl);
$err = curl_error($curl);

curl_close($curl);

if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}
package main

import (
"fmt"
"net/http"
"io"
)

func main() {

url := "https://api.example.com/api/v1/admin/auth/mfa-recovery"

req, _ := http.NewRequest("POST", url, nil)

res, _ := http.DefaultClient.Do(req)

defer res.Body.Close()
body, _ := io.ReadAll(res.Body)

fmt.Println(string(body))

}
HttpResponse<String> response = Unirest.post("https://api.example.com/api/v1/admin/auth/mfa-recovery")
.asString();
require 'uri'
require 'net/http'

url = URI("https://api.example.com/api/v1/admin/auth/mfa-recovery")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)

response = http.request(request)
puts response.read_body

Overview

When the admin’s authenticator app is lost, broken, or otherwise unavailable, the operator falls back to one of the 10 single-use recovery codes that were printed on first MFA enrollment (and replaceable via regenerate). This endpoint replaces the TOTP step of the two-step login flow with a recovery code:
  • The user side still authenticates by email + password — same enumeration-safe ADMIN_INVALID_CREDENTIALS surface as /admin/auth/login.
  • Instead of presenting an mfaTicket + 6-digit TOTP, the caller presents a recovery code directly.
On success the recovery code is consumed (single-use) and a full admin token pair (access + refresh) is issued exactly as if the TOTP step had passed.
A recovery code is single-use. Consumed codes are gone — after a successful call the user has 9 left. When the count gets low, the operator should request a SUPER admin to regenerate the array.

Authentication

None. This is the recovery path — the caller IS authenticating. The endpoint is covered by the 10-requests-per-minute-per-IP burst limiter to prevent code-grinding.

Path parameters

None.

Query parameters

None.

Request body

FieldTypeRequiredNotesExample
emailstringyesValid email, 5..254 chars. Lower-cased server-side.admin@swappr.co.uk
passwordstringyes1..200 chars. The current admin password.correct-horse-battery
recoveryCodestringyes8..64 chars. Production format is ABCD-EFGH-IJKL (14 chars), but the surface accepts a broader range for future format flexibility.ABCD-EFGH-IJKL
deviceFingerprintstringnoOpaque client fingerprint, max 512 chars. Stored on the new refresh token row.fp_9a3c…

Example payload

{
  "email":        "admin@swappr.co.uk",
  "password":     "correct-horse-battery",
  "recoveryCode": "ABCD-EFGH-IJKL"
}

Response — 200 OK

FieldTypeNotes
accessTokenstringAdmin access token, 15-min TTL, scope admin.
refreshTokenstringOpaque refresh token, 7-day TTL.
{
  "accessToken":  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "rt_a1b2c3d4e5f6..."
}

Error responses

StatusCodeMeaning
400VALIDATION_FAILEDAny field missing or out of length bounds.
401ADMIN_INVALID_CREDENTIALSEmail + password verification failed. Enumeration-safe — same code/timing whether the email is unknown or the password is wrong.
401MFA_INVALID_CODEThe recovery code is unknown, already consumed, or otherwise invalid. The full argon2 verify cost is still paid so timing cannot distinguish “consumed” from “wrong”.
429RATE_LIMITEDBurst limiter tripped (10/min/IP).

Example error — 401 MFA_INVALID_CODE

{
  "type": "https://api.swappr.co.uk/errors/mfa-invalid-code",
  "title": "MFA code invalid",
  "status": 401,
  "code": "MFA_INVALID_CODE",
  "detail": "Recovery code invalid or already used",
  "instance": "/api/v1/admin/auth/mfa-recovery",
  "requestId": "01HZQ7K3M4N5P6Q7R8S9T0V1W2"
}

Side effects

EffectNotes
Recovery code consumedThe matched code is removed from the admin’s recovery-code array atomically. Other codes are unaffected.
Refresh token rowNew row inserted with deviceFingerprint and a fresh secret.
Audit rowaction: admin.mfa-recovery, actorType: ADMIN.
Consuming a recovery code does not invalidate the operator’s TOTP device. If the user later finds their authenticator, they can resume using /admin/auth/mfa-verify — only the specific code that was used is gone.

See also

curl

curl -X POST https://api.swappr.co.uk/api/v1/admin/auth/mfa-recovery \
  -H "Content-Type: application/json" \
  -d '{
    "email":        "admin@swappr.co.uk",
    "password":     "correct-horse-battery",
    "recoveryCode": "ABCD-EFGH-IJKL"
  }'

Postman

See docs/postman/swappr.postman_collection.jsonAdmin Auth → MFA Recovery.