{"record":{"id":"f785789e79215c94","repo":"BookStackApp/BookStack","slug":"auth-mfa-throttle","errorCode":null,"errorMessage":"auth.mfa_throttle","messagePattern":"auth\\.mfa_throttle","errorType":"exception","errorClass":"NotifyException","httpStatus":429,"severity":"warning","filePath":"app/Access/Mfa/MfaVerificationLimiter.php","lineNumber":28,"sourceCode":"\n/**\n * A rate limit specifically for MFA verification.\n * Limits across both the attempted user (on a tight limit) and the\n * request IP (on a less strict limit).\n */\nclass MfaVerificationLimiter\n{\n    protected int $maxUserAttemptsPerMinute = 5;\n    protected int $maxIpAttemptsPerMinute = 60;\n\n    public function __construct(\n        protected RateLimiter $rateLimiter\n    ) {\n    }\n\n    public function throwException(): never\n    {\n        throw new NotifyException(\n            trans('auth.mfa_throttle', ['seconds' => 60]),\n            '/login',\n            Response::HTTP_TOO_MANY_REQUESTS\n        );\n    }\n\n    public function incrementAttempts(User $user, Request $request): void\n    {\n        $this->rateLimiter->hit($this->getUserKey($user));\n        $this->rateLimiter->hit($this->getRequestKey($request));\n    }\n\n    public function decrementAttempts(User $user, Request $request): void\n    {\n        $this->rateLimiter->decrement($this->getUserKey($user));\n        $this->rateLimiter->decrement($this->getRequestKey($request));\n    }\n","sourceCodeStart":10,"sourceCodeEnd":46,"githubUrl":"https://github.com/BookStackApp/BookStack/blob/18f8469a1c72f8cc8497e9372635e6dea5028071/app/Access/Mfa/MfaVerificationLimiter.php#L10-L46","documentation":"When MFA verification is attempted too many times, MfaVerificationLimiter::throwException raises a NotifyException with the translated 'auth.mfa_throttle' message (including a 60-second wait) and HTTP 429, redirecting the user to /login. This is intentional rate limiting to slow brute-force MFA code guessing.","triggerScenarios":"A user exceeds the rate limit for MFA code verification attempts within the limiter's window (RateLimiter hit limit), so throwException() fires on the next attempt instead of validating the code.","commonSituations":"User repeatedly typing wrong MFA codes; automated scripts or tests hammering the MFA endpoint; shared NAT/proxy where many users share one IP so the limiter trips collectively; clock/session issues causing retries in a loop.","solutions":["Wait 60 seconds (or the configured throttle window) before attempting MFA verification again","Clear the rate limiter key (RateLimiter::clear) in admin/testing contexts to reset the counter","Ask affected users to check they are entering the correct current TOTP/backup code to avoid repeat failures","Increase the limiter threshold or window in code if it is too aggressive for your user base","Ensure automated clients back off on HTTP 429 instead of retrying immediately"],"exampleFix":"// before (test/automation retry loop)\nwhile (!$mfaService->verifyCode($user, $code)) { /* retry immediately */ }\n// after\nif ($attempts >= $maxAttempts) {\n    sleep(60); // respect the mfa throttle window\n    break;\n}\n$mfaService->verifyCode($user, $code);","handlingStrategy":"retry","validationCode":"// check remaining attempts before verifying\nif (RateLimiter::tooManyAttempts($mfaKey, $maxAttempts)) {\n    $seconds = RateLimiter::availableIn($mfaKey);\n    return back()->withErrors(\"Try again in {$seconds} seconds\");\n}","typeGuard":null,"tryCatchPattern":"try {\n    $mfaService->verifyCode($user, $code);\n} catch (NotifyException $e) {\n    if ($e->getStatusCode() === 429) {\n        return redirect($e->redirectTo())->withErrors($e->getMessage()); // show throttle message\n    }\n    throw $e;\n}","preventionTips":["Clients must back off on HTTP 429 instead of retrying immediately","Show a countdown UI to users after failed MFA attempts","Clear rate limiter keys between test runs","Ensure TOTP clock sync so users don't fail codes repeatedly"],"tags":["auth","mfa","rate-limit","http-429"],"backgroundTag":"rate-limit-exceeded","analyzedSha":"18f8469a1c72f8cc8497e9372635e6dea5028071","analyzedAt":"2026-09-02T19:49:33.068Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-10T02:17:09.455Z"}