POST /v1/auth/mfa/challenge
Exchange an mfa_token (issued by /v1/auth/login when the user has MFA
enabled) plus a TOTP code (or a recovery code) for a real browser session.
::: tip Auth
Required: none. The mfa_token IS the credential.
:::
Request
POST /v1/auth/mfa/challenge
| Header | Required | Notes |
|---|---|---|
Content-Type: application/json | yes | — |
Body
{
"mfa_token": "eyJhbGciOiJIUzI1NiIs...",
"code": "123456"
}
OR
{
"mfa_token": "eyJhbGciOiJIUzI1NiIs...",
"recovery_code": "Yj7nKp2qA8"
}
| Field | Type | Required | Notes |
|---|---|---|---|
mfa_token | string | yes | The token returned by /v1/auth/login with status: "mfa_required". 5-minute lifetime. |
code | string | null | one of | Current TOTP code. |
recovery_code | string | null | one of | A single-use recovery code (consumed on success). |
Exactly one of code or recovery_code must be supplied.
Response
200 OK
A full LoginResponse::Success body — identical shape to /v1/auth/login's
success response. Session cookies are set: identsphere_at, identsphere_rt, identsphere_csrf.
The session is minted at AAL2 (auth_method: "password_with_mfa").
Error responses
| Status | Code | When |
|---|---|---|
| 400 | invalid_input | Neither code nor recovery_code was supplied; mfa_token field empty. |
| 401 | authentication_required | mfa_token is invalid, expired, or has already been burned; the user no longer has MFA enabled; the code doesn't match; the recovery code is wrong or already used. |
| 429 | rate_limited | More than 5 failed attempts against this mfa_token. The token is locked for its remaining lifetime. |
| 500 | internal_error | TOTP build or DB failure. |
Example: curl
curl -X POST https://auth.example.com/v1/auth/mfa/challenge \
-H 'Content-Type: application/json' \
-c cookies.txt \
-d '{
"mfa_token": "eyJ...",
"code": "123456"
}'
Example: TypeScript (@identsphere/react)
import { useMfaChallenge } from '@identsphere/react';
function MfaChallengeForm({ mfaToken }: { mfaToken: string }) {
const challenge = useMfaChallenge();
return (
<form onSubmit={async (e) => {
e.preventDefault();
const code = new FormData(e.currentTarget).get('code') as string;
await challenge.mutateAsync({ mfa_token: mfaToken, code });
navigate('/dashboard');
}}>
<input name="code" placeholder="123456" />
<button type="submit">Verify</button>
</form>
);
}
Notes
::: warning Single-use tokens
Each mfa_token can yield at most one successful session. After a successful
challenge the token's jti is burned for 10 minutes (longer than the token's
own lifetime) so concurrent replays fail.
:::
- Brute-force protection: 5 failed attempts against the same
mfa_tokentriggersrate_limited. The user must restart the login flow to get a fresh token. - Recovery codes are single-use; the row is marked
is_used = trueon success. - Audit entries (
auth.mfa.challenge.succeeded,auth.mfa.challenge.failed,auth.mfa.challenge.locked) are recorded for every attempt. - The resulting session sits at AAL2. Step-up endpoints will accept this session as already-verified for the full step-up TTL.