Skip to content

security(auth): MFA challenge codes can be guessed without limit — no attempt counter, no rate limit #689

Closed

Description

@alex-dembele

Problem

The second factor can be guessed without limit. After someone passes the password check, nothing stops them from submitting every TOTP code until one matches, which defeats MFA for anyone who holds a leaked or phished password.

Found by reading the code (2026-09-15), not exploited

  • No throttle on the route: POST /api/v1/auth/mfa/challenge (backend/cmd/server/main.go) is behind MFATokenMiddleware only. It has no rate limiter, unlike /auth/login.
  • No attempt counter:
    • MFAHandler.Challenge (backend/internal/handler/auth/mfa_handler.go) and ChallengeMFAUseCase.Execute (backend/internal/application/auth/mfa_usecase.go) verify the code, then fall back to backup codes;
    • no failed attempt is counted;
    • the challenge token is not invalidated after failures.
  • Long window: the challenge token lives MFAChallengeTTL = 5 * time.Minute and can be reused for every attempt in that window. A new one is one more login away.
  • The arithmetic: a 6-digit TOTP with its tolerance window leaves a few valid codes out of 10^6. Unthrottled requests over 5 minutes, repeated across fresh challenge tokens, make a guess realistic.
  • Audit only: each failure is written to the audit log, and nothing acts on it.

Also noted, lower severity: POST /auth/logout revokes the refresh token and clears cookies, but the access token stays valid until its 15-minute TTL, although a JTI blacklist exists (jtiBlacklistChecker).

Acceptance criteria

  1. Per-token limit: after 5 failed codes on one challenge token, that token is dead (JTI blacklisted), and further attempts with it answer 401.
  2. Per-account limit: failed MFA attempts for one user are counted across challenge tokens and IPs. After a threshold in a window, challenges for that user are refused for a bounded time, and the event is audited.
  3. Per-IP limit: the route has its own IP rate limit, separate from the login bucket (see the related throttle issue).
  4. Backup codes: backup-code attempts count towards the same limits.
  5. Logout: the access token's JTI is blacklisted, so the access token stops working immediately.
  6. Tests:
    • TestMFAChallenge_TokenDiesAfterFiveFailures
    • TestMFAChallenge_UserBackoffAcrossTokens
    • TestMFAChallenge_BackupCodesCounted
    • TestLogout_AccessTokenRejectedAfterLogout

Definition of Done

  • go build ./... && go vet ./... && go test ./... -race green, with output pasted here.
  • Manual check against a local instance, with an enrolled test account, for criteria 1, 2 and 5.
  • Progress comment in the CLAUDE.md format; PR with Closes this issue.

Activity

  1. added this to the trust-v1 milestone on Sep 15, 2026
  2. alex-dembele commented on Oct 2, 2026

    @alex-dembele
    MemberAuthor

    backend — 2026-10-02

    Done

    • Spec and plan: docs/689_MFA_CHALLENGE_LIMITS.md, with the owner's decisions: limits as proposed, a lock email, and the frontend copy split into fix(frontend): the MFA code screen does not tell users to sign in again or wait after too many wrong codes #872.
    • Attempt store: backend/internal/infrastructure/authmfa/attempts.go, plus the MFAAttemptStore and TokenRevoker ports in backend/internal/application/auth/mfa_limits.go.
      • It uses Redis and falls back to per-instance memory, so it never fails open.
      • I didn't reuse AllowRate, because a 5-minute token can straddle two of its wall-clock buckets.
    • Limits: ChallengeMFAUseCase (mfa_usecase.go, mfa_limits.go).
      • Each attempt is reserved against the token and the account before the code is read.
      • A token allows 5 attempts, then its JTI is blacklisted.
      • An account takes 10 failures in 15 min, then a fixed 15-min lock.
      • Backup codes count as the same attempt as TOTP codes.
    • Lock email in FR and EN: backend/internal/infrastructure/authmail/reset_mailer.go, async.go.
    • HTTP: backend/internal/handler/auth/mfa_handler.go answers 401 MFA_CHALLENGE_EXHAUSTED and 429 MFA_LOCKED with Retry-After. The lock is audited once (mfa_locked). In backend/cmd/server/main.go, the route has a per-IP limit of 20 per 5 min, in its own prefixed bucket, ahead of the token middleware.
    • Logout: backend/internal/application/auth/logout.go and backend/internal/handler/auth/handler.go.
      • It verifies the bearer or cookie access token and blacklists its JTI.
      • The access token is revoked before the refresh token, and both are always attempted.

    Verified

    • go build ./... && go vet ./... && go test ./... -race on the final tip (f4b34f32): gate-exit=0, 80 packages ok, 0 FAIL.
    • New tests:
      • TestMFAChallenge_TokenDiesAfterFiveFailures, _UserBackoffAcrossTokens, _BackupCodesCounted, _Success, _NotFound, _Unauthorized, _SuccessClearsFailureCount, _LockSendsOneEmail, _StoreErrorFailsClosed;
      • TestLogout_AccessTokenRejectedAfterLogout (over HTTP, plus a cookie variant and a forged-token case), and TestLogout_Success, _NotFound, _Unauthorized, _BlacklistOutageStillRevokesRefreshToken;
      • TestMFAChallengeE2E_* (4);
      • authmfa store (4) and authmail lock email (2).
    • The logout test fails with revocation unwired (expected: 401, actual: 200), so it catches the bug.
    • Live, on a local instance (Postgres 18, Redis 7, SMTP sink), with the admin account enrolled in TOTP:
    # criterion 1 — one challenge token
    attempt 1-4 (wrong code): 400 invalid MFA code
    attempt 5 (wrong code):   401 MFA_CHALLENGE_EXHAUSTED
    attempt 6 (right code):   401 TOKEN_REVOKED          (JTI present in Redis token_blacklist)
    # criterion 2 — three tokens
    failure 1-9:   400 invalid MFA code
    failure 10:    429 MFA_LOCKED Retry-After=900
    right code, fresh token, during lock: 429 MFA_LOCKED Retry-After=900
    mail: 1 message to admin@opendefender.io, "Trop de codes erronés : la connexion à deux facteurs de votre compte OpenRisk est suspendue"
    audit: mfa_locked ×1, mfa_challenge_exhausted ×1, invalid MFA code ×13
    after the lock expired on its own (15 min): right code → 200, session issued
    # criterion 3 — same IP
    challenge 19, 20: 401 (reach token check) · 21, 22: 429 · /auth/login from same IP: 200
    # criterion 5 — logout
    /auth/me before: 200 · logout: 200 · /auth/me with the same token: 401 TOKEN_REVOKED
    

    Criteria: 1 ✅ · 2 ✅ · 3 ✅ · 4 ✅ · 5 ✅ · 6 ✅ (the four named tests exist and pass)

    Next: owner review of the PR. Then #872, so the sign-in screen tells users to start over or wait instead of showing "invalid code".

    Blocked on: nothing.

    Not done, deliberately:

    • Challenge token after success: it isn't revoked after a successful code. It can mint sessions until it expires, though each use counts towards its 5 attempts. This is outside the criteria and a one-line change if wanted.
    • Redis down: the JTI blacklist still fails open, as before, so a logged-out access token keeps working until it expires. The challenge counters themselves fall back to memory and do not fail open.

    Branch note:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions