appwrite/appwrite · error · Appwrite\Extend\Exception
user_challenge_required
user_challenge_required
Error message
A recently successful challenge is required to complete this action. A challenge is considered recent for 5 minutes.
What it means
Thrown by the MFA-recent-challenge init middleware when the session's `mfaUpdatedAt` timestamp is older than `MFA_RECENT_DURATION` (5 minutes) or missing entirely. Sensitive MFA-gated operations require a freshly completed challenge; this enforces step-up authentication before allowing factor changes, disabling MFA, or other high-risk actions.
Source
Thrown at app/controllers/shared/api/auth.php:30
use Utopia\Http\Route;
use Utopia\System\System;
Http::init()
->groups(['mfaProtected'])
->inject('session')
->action(function (Document $session) {
$isSessionFresh = false;
$lastUpdate = $session->getAttribute('mfaUpdatedAt');
if (!empty($lastUpdate)) {
$now = DateTime::now();
$maxAllowedDate = DateTime::addSeconds(new \DateTime($lastUpdate), MFA_RECENT_DURATION); // Maximum date until session is considered safe before asking for another challenge
$isSessionFresh = DateTime::formatTz($maxAllowedDate) >= DateTime::formatTz($now);
}
if (!$isSessionFresh) {
throw new Exception(Exception::USER_CHALLENGE_REQUIRED);
}
});
Http::init()
->groups(['auth'])
->inject('route')
->inject('request')
->inject('project')
->inject('geoRecord')
->inject('user')
->inject('authorization')
->action(function (Route $route, Request $request, Document $project, GeoRecord $geoRecord, User $user, Authorization $authorization) {
$denylist = System::getEnv('_APP_CONSOLE_COUNTRIES_DENYLIST', '');
if (!empty($denylist) && $project->getId() === 'console') {
// A missing or unknown geo lookup ("--") is treated as allowed and falls
// through to the membership check below, matching the pre-geo-service behavior.
$countries = \array_map('strtoupper', \array_map('trim', explode(',', $denylist)));
$country = \strtoupper($geoRecord->getCountryCode());View on GitHub (pinned to cd368e707d)
Solutions
- Complete a fresh challenge immediately before the sensitive action: `POST /v1/account/mfa/challenge` then verify it.
- Redesign the flow to perform the sensitive action right after challenge verification, within the 5-minute window.
- If the window is genuinely too short for your UX, document the step-up pattern so users re-verify deliberately.
Example fix
// before — using a stale challenge await mfa.deleteFactor(factorId); // >5min since last challenge → throws // after — refresh challenge first const c = await mfa.createChallenge(factorId); await mfa.updateChallenge(c.$id, code); await mfa.deleteFactor(factorId);
Defensive patterns
Strategy: try-catch
Validate before calling
// Track last challenge time client-side; if >5min, refresh before sensitive op
const since = Date.now() - lastChallengeAt;
if (since > 5 * 60 * 1000) { await runChallenge(); lastChallengeAt = Date.now(); } Type guard
function isChallengeRequired(e: any): boolean {
return e?.code === 412 && e?.type === 'user_challenge_required';
} Try / catch
try {
await mfa.deleteFactor(factorId);
} catch (e) {
if (e?.type === 'user_challenge_required') {
const c = await mfa.createChallenge(factorId);
await mfa.updateChallenge(c.$id, code);
await mfa.deleteFactor(factorId);
} else throw e;
} Prevention
- Run the sensitive MFA operation immediately after challenge verification.
- Redesign long UX flows to re-verify within the 5-minute window.
- Track `mfaUpdatedAt` client-side to anticipate expiry.
When it happens
Trigger: Calling an MFA-group route (`DELETE /v1/account/mfa/factor`, `PUT /v1/account/mfa/authenticator`, etc.) more than 5 minutes after the last challenge, or with a session that never completed a challenge. The `mfaUpdatedAt` field on the session tracks the last verified challenge time.
Common situations: User leaves the MFA management screen idle >5 min before confirming; long UX flows that span the 5-minute window; session restored from storage without refreshing the challenge; tests that complete one challenge and reuse it for many operations.
Related errors
AI-assisted analysis of appwrite/appwrite@cd368e707d (2026-08-12).
Data as JSON: /api/errors/30922190b8c79ed9.
Report an issue: GitHub.