{"record":{"id":"30922190b8c79ed9","repo":"appwrite/appwrite","slug":"user-challenge-required","errorCode":"user_challenge_required","errorMessage":"A recently successful challenge is required to complete this action. A challenge is considered recent for 5 minutes.","messagePattern":"A recently successful challenge is required to complete this action\\. A challenge is considered recent for 5 minutes\\.","errorType":"exception","errorClass":"Appwrite\\Extend\\Exception","httpStatus":401,"severity":"error","filePath":"app/controllers/shared/api/auth.php","lineNumber":30,"sourceCode":"use Utopia\\Http\\Route;\nuse Utopia\\System\\System;\n\nHttp::init()\n    ->groups(['mfaProtected'])\n    ->inject('session')\n    ->action(function (Document $session) {\n        $isSessionFresh = false;\n\n        $lastUpdate = $session->getAttribute('mfaUpdatedAt');\n        if (!empty($lastUpdate)) {\n            $now = DateTime::now();\n            $maxAllowedDate = DateTime::addSeconds(new \\DateTime($lastUpdate), MFA_RECENT_DURATION); // Maximum date until session is considered safe before asking for another challenge\n\n            $isSessionFresh = DateTime::formatTz($maxAllowedDate) >= DateTime::formatTz($now);\n        }\n\n        if (!$isSessionFresh) {\n            throw new Exception(Exception::USER_CHALLENGE_REQUIRED);\n        }\n    });\n\nHttp::init()\n    ->groups(['auth'])\n    ->inject('route')\n    ->inject('request')\n    ->inject('project')\n    ->inject('geoRecord')\n    ->inject('user')\n    ->inject('authorization')\n    ->action(function (Route $route, Request $request, Document $project, GeoRecord $geoRecord, User $user, Authorization $authorization) {\n        $denylist = System::getEnv('_APP_CONSOLE_COUNTRIES_DENYLIST', '');\n        if (!empty($denylist) && $project->getId() === 'console') {\n            // A missing or unknown geo lookup (\"--\") is treated as allowed and falls\n            // through to the membership check below, matching the pre-geo-service behavior.\n            $countries = \\array_map('strtoupper', \\array_map('trim', explode(',', $denylist)));\n            $country = \\strtoupper($geoRecord->getCountryCode());","sourceCodeStart":12,"sourceCodeEnd":48,"githubUrl":"https://github.com/appwrite/appwrite/blob/cd368e707d4b492bc5e8e9c0f8ecbc5b741c4bf4/app/controllers/shared/api/auth.php#L12-L48","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before — using a stale challenge\nawait mfa.deleteFactor(factorId); // >5min since last challenge → throws\n\n// after — refresh challenge first\nconst c = await mfa.createChallenge(factorId);\nawait mfa.updateChallenge(c.$id, code);\nawait mfa.deleteFactor(factorId);","handlingStrategy":"try-catch","validationCode":"// Track last challenge time client-side; if >5min, refresh before sensitive op\nconst since = Date.now() - lastChallengeAt;\nif (since > 5 * 60 * 1000) { await runChallenge(); lastChallengeAt = Date.now(); }","typeGuard":"function isChallengeRequired(e: any): boolean {\n  return e?.code === 412 && e?.type === 'user_challenge_required';\n}","tryCatchPattern":"try {\n  await mfa.deleteFactor(factorId);\n} catch (e) {\n  if (e?.type === 'user_challenge_required') {\n    const c = await mfa.createChallenge(factorId);\n    await mfa.updateChallenge(c.$id, code);\n    await mfa.deleteFactor(factorId);\n  } else throw e;\n}","preventionTips":["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."],"tags":["mfa","security","authentication","step-up"],"backgroundTag":null,"analyzedSha":"cd368e707d4b492bc5e8e9c0f8ecbc5b741c4bf4","analyzedAt":"2026-08-12T14:42:48.571Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}