Skip to main content

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

HeaderRequiredNotes
Content-Type: application/jsonyes

Body

{
"mfa_token": "eyJhbGciOiJIUzI1NiIs...",
"code": "123456"
}

OR

{
"mfa_token": "eyJhbGciOiJIUzI1NiIs...",
"recovery_code": "Yj7nKp2qA8"
}
FieldTypeRequiredNotes
mfa_tokenstringyesThe token returned by /v1/auth/login with status: "mfa_required". 5-minute lifetime.
codestring | nullone ofCurrent TOTP code.
recovery_codestring | nullone ofA 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

StatusCodeWhen
400invalid_inputNeither code nor recovery_code was supplied; mfa_token field empty.
401authentication_requiredmfa_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.
429rate_limitedMore than 5 failed attempts against this mfa_token. The token is locked for its remaining lifetime.
500internal_errorTOTP 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_token triggers rate_limited. The user must restart the login flow to get a fresh token.
  • Recovery codes are single-use; the row is marked is_used = true on 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.