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

  1. Complete a fresh challenge immediately before the sensitive action: `POST /v1/account/mfa/challenge` then verify it.
  2. Redesign the flow to perform the sensitive action right after challenge verification, within the 5-minute window.
  3. 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

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.