---
key: "mfa-account-takeover"
title: "Stop account takeover with the right MFA and login defenses"
url: "https://softwaresecurity.com/domains/identity-crypto/authentication/mfa-account-takeover/"
api: "https://softwaresecurity.com/api/v1/articles/mfa-account-takeover.json"
domain: "identity-crypto"
topic: "Identity \u0026 cryptography"
type: "Guide"
identifiers: ["CWE-307","CWE-308","CWE-294","CWE-204","CWE-208","CWE-521","CWE-640","A07:2025","API2:2023","V6.2","V6.3","V6.4","V6.5","V6.6","V2.4","CAPEC-600","CAPEC-565","CAPEC-49","CAPEC-94","CAPEC-575"]
tags: ["Account takeover","Authentication and sessions","MFA and passkeys"]
stages: ["build"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Stop account takeover with the right MFA and login defenses

Rank MFA options by the attacks they stop, implement TOTP and push approvals correctly, and close the login, reset and recovery paths that attackers use instead of breaking the second factor.

Canonical URL: https://softwaresecurity.com/domains/identity-crypto/authentication/mfa-account-takeover/

## In short

Make passkeys or FIDO2 security keys the default: they resist phishing, and NIST SP 800-63B-4 requires AAL2 verifiers to offer a phishing-resistant option. Verify TOTP against the server clock with at most one step of skew, accept each time step once and limit failures per account. Push prompts must make users type a code from the login screen. Treat SMS as restricted. Throttle by account and network, reject breached passwords and answer identically for unknown accounts.

- Only cryptographic authenticators such as passkeys resist phishing; a real-time proxy can relay any code the user reads and types.
- A TOTP verifier must refuse a code it has already accepted: store the last accepted time step and claim each new one atomically.
- SP 800-63B-4 doesn't accept approve-or-deny push, and comparing or picking a number on screen is not enough; the user must enter the code.
- Count failures for identifiers that don't exist too, or the lockout itself tells attackers which accounts are real.
- Recovery sets the real strength of MFA: recovery codes need at least 64 bits each, hashed storage, single use and a notification.
- A correct password followed by a failed or denied second factor usually means the password is known: notify the user and ask for a change.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| TOTP secret | 20 random bytes (160 bits) from a CSPRNG, encrypted at rest | SP 800-63B-4 sets a 112-bit minimum. The verifier needs the plaintext key, so encrypt it rather than hashing it. | RFC 4226 R6; SP 800-63B-4 3.1.4.1 |
| TOTP algorithm, digits, step | HMAC-SHA-1, 6 digits, 30 seconds | RFC 6238 defaults; RFC 4226 requires at least 6 digits. Google Authenticator ignores the algorithm and period parameters. ASVS 5.0 lists HMAC-SHA-1 as legacy. | RFC 6238 1.2, 4.1; RFC 4226 5.3; otpauth key URI format |
| TOTP verification window | Current step plus at most one step either side | Server clock only. RFC 6238 recommends at most one step for network delay; ASVS 5.0 caps a TOTP's lifetime at 30 seconds. | RFC 6238 5.2; ASVS 5.0 6.5.5, 6.5.8 |
| TOTP replay | Accept only time steps later than the last accepted one | A code already used, or an unused older code, is refused. | RFC 6238 5.2; SP 800-63B-4 3.1.4.2 |
| Failed attempts per authenticator | At most 100 consecutive failures, with delays or challenges well before that | Charge the attempt before checking it so parallel guesses can't exceed the limit. Reset after a successful login. | SP 800-63B-4 3.2.2 |
| Push approval | User types a code of at least 6 digits shown on the login screen | Approve-or-deny and pick-from-a-list prompts don't qualify. Cap prompts sent since the last successful login. | SP 800-63B-4 3.1.3.1, 3.1.3.2 |
| SMS, voice and push secrets | At least 6 digits, valid at most 10 minutes, single use | Generating a new code must not reset the failure count. SMS and voice are restricted authenticators. | SP 800-63B-4 3.1.3.2, 3.1.3.3 |
| Saved recovery codes | At least 64 random bits each, salted password hash, single use | For example 16 hex characters in four groups. Throttle like login and notify on every use. | SP 800-63B-4 4.2.1.1; ASVS 5.0 6.5.2 |
| Password length | 15+ characters if used alone, 8+ if always paired with MFA; allow at least 64 | Count Unicode code points. No composition rules or forced rotation; check a blocklist of breached and common passwords. | SP 800-63B-4 3.1.1.2 |

## Verify it

1. Submit one valid TOTP code twice within the same 30 seconds, then a code from two steps ago: all but the first must fail.
2. Send 20 concurrent requests with one valid code: exactly one may succeed. Send 50 concurrent wrong codes: no more than the limit may be evaluated.
3. Send 11 wrong passwords for a real account and for an address with no account: status, body, headers, timing and the point where throttling starts must match.
4. Request more push prompts than the cap and confirm that sending stops and an alert fires; resend an SMS code and confirm the failure count did not reset.
5. From a session that has passed only the password step, try to call an API, add a factor, change the phone number and create an API token: each must demand the second factor.
6. List every way to sign in (web, mobile API, OAuth token endpoint, legacy protocols, password reset) and confirm each one enforces the same factors.

## What you will learn

- Rank authenticators by the attacks they stop and apply the SP 800-63B-4 rules for push, SMS and passkeys.
- Implement a TOTP verifier with a skew window, replay protection and a race-free attempt limit.
- Design throttling, breached-password checks and enumeration-safe responses for login, reset and registration.
- Find the fallback and recovery paths that undo MFA, and test that each enforces the same factors.

Audience: Developers and AppSec engineers who build or review sign-in, MFA and account recovery

Before you start:

- Know how your application creates a session after login.
- Read Python and JavaScript.
- Understand password hashing at a basic level.

Time: About 34 minutes: 11 to read and 23 for the exercise

## Which MFA method is strongest?

Passkeys and FIDO2 security keys. They are the only common second factors that a phishing site cannot relay, because the browser binds each signature to your domain. Next come authenticator-app codes (TOTP) and push prompts that make the user type a number from the login screen, which stop password reuse but not real-time phishing. SMS and voice codes are the weakest acceptable option, and approve-only push prompts and email codes are not acceptable under NIST SP 800-63B-4. For administrators and high-value accounts, require a device-bound security key.

Account takeover rarely defeats a second factor head-on. Attackers replay passwords leaked elsewhere, relay one-time codes through a fake login page, wear users down with push prompts, port the phone number or skip MFA through recovery. Rank each option by which of those it stops, make the strongest the default, and keep the rest as fallbacks you plan to retire.

NIST SP 800-63B-4 (final, July 2025) counts only cryptographic authenticators as phishing-resistant, because any code a user reads and types can be relayed. It requires AAL2 verifiers to offer at least one phishing-resistant option, marks SMS and voice codes as restricted, forbids email as an out-of-band authenticator and keeps synced passkeys out of AAL3.

| Factor | Phishing-resistant | Main weakness | Status in SP 800-63B-4 |
| --- | --- | --- | --- |
| Passkey or FIDO2 security key (WebAuthn) | Yes; the credential only works for your domain | Weaker fallbacks and recovery | Phishing-resistant; synced passkeys up to AAL2, device-bound keys for AAL3 |
| TOTP authenticator app | No | Relayed by phishing proxies; seeds stolen from the server | Allowed; accept each code once and limit failures |
| Push with a typed code | No | Relayed by phishing proxies | Allowed when the user transfers the code |
| Push with approve or deny only | No | MFA fatigue (push bombing) | Not acceptable |
| SMS or voice code | No | SIM swap, number porting, interception | Restricted: needs an unrestricted alternative and a risk notice |
| Email code or magic link | No | Mailbox takeover, often with the same reused password | Not allowed for out-of-band authentication |
| Recovery codes | No | Stolen from wherever the user keeps them | Recovery only: 64+ bits, hashed, single use |

## Verify TOTP codes once, against the server clock

TOTP (RFC 6238) derives a six-digit code from a shared secret and the current 30-second step. Generate a 160-bit secret and encrypt it at rest; the verifier needs the plaintext key, so a hash won't work. Keep HMAC-SHA-1, six digits and 30 seconds unless you control the app; Google Authenticator, for one, ignores other algorithm and period settings. ASVS 5.0 lists HMAC-SHA-1 as legacy, acceptable for compatibility.

Check codes against the server's clock, never a time the client sends. Allow at most one step either side for drift and typing; every extra step lengthens the time a phished code stays valid. Accept each code once: RFC 6238 says the verifier MUST NOT accept a second use, so store the last accepted step and accept only later ones. Limit failures per account across sessions. With a one-step window three of the million possible codes are valid at any moment, so an attacker making 1,000 guesses a second expects a hit in about six minutes.

The verifier below charges each attempt before checking it, so parallel guesses can't exceed the limit, and claims the matched step with a conditional `UPDATE`, so two simultaneous submissions of one code can't both succeed. It passes the RFC 6238 vectors and was tested on SQLite and on PostgreSQL 18 at its default `READ COMMITTED` isolation.

```python
import hashlib
import hmac
import re
import secrets
import struct
import time

STEP, DIGITS, SKEW = 30, 6, 1   # 30-second steps, 6 digits, accept T-1, T and T+1
MAX_FAILURES = 10               # consecutive failures before the factor is locked
CODE = re.compile(r"[0-9]{6}")  # ASCII digits only; str.isdigit() also accepts "²" and "١"


def new_secret() -> bytes:
    return secrets.token_bytes(20)  # 160 bits; store it encrypted, never hashed


def hotp(key: bytes, counter: int, digits: int = DIGITS, digest=hashlib.sha1) -> str:
    mac = hmac.new(key, struct.pack(">Q", counter), digest).digest()
    offset = mac[-1] & 0x0F
    value = int.from_bytes(mac[offset:offset + 4], "big") & 0x7FFFFFFF
    return str(value % 10**digits).zfill(digits)


def matching_step(key: bytes, code: str, now: float) -> int | None:
    if not CODE.fullmatch(code):
        return None
    current = int(now // STEP)
    match = None
    for step in range(current - SKEW, current + SKEW + 1):
        if hmac.compare_digest(hotp(key, step), code):
            match = step
    return match


def verify_totp(db, user_id: int, code: str, now: float | None = None) -> bool:
    now = time.time() if now is None else now  # the server's clock, never the client's
    # Charge the attempt first, so parallel guesses cannot exceed the limit.
    cur = db.execute(
        "UPDATE totp SET failures = failures + 1 WHERE user_id = ? AND failures < ?",
        (user_id, MAX_FAILURES),
    )
    db.commit()
    if cur.rowcount != 1:
        return False  # no TOTP factor, or locked until recovery
    (secret,) = db.execute("SELECT secret FROM totp WHERE user_id = ?", (user_id,)).fetchone()
    step = matching_step(secret, code, now)
    if step is None:
        return False
    # Accept each time step once, and never a step older than the last accepted one.
    cur = db.execute(
        "UPDATE totp SET last_step = ?, failures = 0 "
        "WHERE user_id = ? AND (last_step IS NULL OR last_step < ?)",
        (step, user_id, step),
    )
    db.commit()
    return cur.rowcount == 1
```

## Test replay, skew and the attempt limit

Pin the behavior in tests: the RFC vectors, a replay inside one step, an unused older code after a newer one, the limit under parallel guessing and one code submitted many times at once.

```python
import sqlite3
import threading

from totp import MAX_FAILURES, STEP, hotp, verify_totp

KEY, NOW = b"12345678901234567890", 1_791_616_580


def db(path=":memory:"):
    conn = sqlite3.connect(path, timeout=30, check_same_thread=False)
    conn.execute("CREATE TABLE IF NOT EXISTS totp (user_id INTEGER PRIMARY KEY, secret BLOB NOT NULL, "
                 "last_step INTEGER, failures INTEGER NOT NULL DEFAULT 0)")
    conn.execute("INSERT OR REPLACE INTO totp (user_id, secret) VALUES (1, ?)", (KEY,))
    conn.commit()
    return conn


def code(t):
    return hotp(KEY, int(t // STEP))


def test_rfc6238_sha1_vectors():
    for t, expected in [(59, "94287082"), (1111111109, "07081804"), (2000000000, "69279037")]:
        assert hotp(KEY, t // 30, digits=8) == expected


def test_replay_and_skew():
    conn = db()
    assert verify_totp(conn, 1, code(NOW), NOW)
    assert not verify_totp(conn, 1, code(NOW), NOW + 10)            # same code again
    assert not verify_totp(db(), 1, code(NOW - 2 * STEP), NOW)       # outside the window
    conn = db()
    assert verify_totp(conn, 1, code(NOW + STEP), NOW)               # phone clock 30 s ahead
    assert not verify_totp(conn, 1, code(NOW), NOW)                  # older step, never used


def test_limit_holds_against_parallel_guesses(tmp_path):
    path = str(tmp_path / "t.db")
    db(path).close()
    wrong = "000000" if code(NOW) != "000000" else "111111"
    threads = [threading.Thread(target=lambda: verify_totp(sqlite3.connect(path, timeout=30), 1, wrong, NOW))
               for _ in range(30)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    conn = sqlite3.connect(path)
    assert conn.execute("SELECT failures FROM totp").fetchone()[0] == MAX_FAILURES
    assert not verify_totp(conn, 1, code(NOW), NOW)                  # locked: right code refused


def test_one_code_cannot_win_twice_concurrently(tmp_path):
    path = str(tmp_path / "t.db")
    db(path).close()
    results = []
    threads = [threading.Thread(target=lambda: results.append(
        verify_totp(sqlite3.connect(path, timeout=30), 1, code(NOW), NOW))) for _ in range(16)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    assert results.count(True) == 1
```

## Make push approvals carry a code, and cap them

MFA fatigue works because an approve-or-deny prompt asks nothing of the user. In September 2022, Uber said an attacker who had probably bought a contractor's password kept triggering login approval requests until the contractor accepted one; Uber believed the attacker was affiliated with Lapsus$. CISA calls this push bombing and recommends number matching as an interim step toward phishing-resistant MFA. Microsoft Authenticator now enables number matching for all push notifications.

SP 800-63B-4 goes further. The user must transfer a secret between the login screen and the device, the secret must be at least six digits, and choosing from a short list doesn't qualify, because users guess.

- Show a code on the login page, make the user type it into the app, and bind the approval to that login transaction.
- Cap the prompts sent since the last successful login, then switch to another factor (SP 800-63B-4 3.1.3.2; ASVS 5.0 6.6.4).
- Treat a denied or reported prompt as a sign the password is known: alert the security team and ask the user to change it.
- Expire push, SMS and voice secrets within 10 minutes and accept each one once.

## Is SMS MFA still acceptable?

Only as a restricted fallback. An SMS code still stops most credential stuffing, which is far better than a password alone, but SIM swaps, number porting, interception and phishing proxies defeat it. NIST SP 800-63B-4 treats SMS and voice as restricted authenticators: offer at least one unrestricted alternative, tell users the risk, and plan the move to passkeys or an authenticator app. Do not rely on SMS for administrators, account recovery of high-value accounts or anyone likely to be targeted.

SMS and voice codes fall to SIM swaps, number porting, interception and phishing. SP 800-63B-4 still permits them as a restricted authenticator if you offer at least one unrestricted alternative, explain the risk to users and plan a migration. Check signals such as a recent SIM change or port before sending a code. Changing the phone number counts as binding a new authenticator, so it needs authentication at the account's highest level and a notification. A resend must not reset the failure count. ASVS 5.0 drops SMS and phone entirely at level 3, and CISA's December 2024 guidance tells highly targeted people not to use SMS as a second factor.

## Make passkeys the default and stop fallbacks undoing them

A WebAuthn credential is scoped to your relying-party ID, and the browser reports the origin it was used on, so a look-alike domain gets nothing it can replay. WebAuthn Level 3 became a W3C Recommendation in August 2026. On each assertion, check the challenge, the origin and the `rpIdHash`, and require the UV flag when the passkey stands in for both factors. Synced passkeys are copied between a user's devices, so SP 800-63B-4 allows them at AAL2 but not AAL3; the BE and BS flags show whether a credential can be, and is, backed up.

Phishing resistance holds only while nothing weaker remains. Once a user has a passkey, stop offering SMS or email codes at login, require a factor at the same level to add or remove factors, and send lost-device cases through recovery. The passkey guide at /domains/identity-crypto/authentication/passkeys-mfa-lifecycle/ covers registration and verification.

## Give recovery the same strength as login

When every factor is strong, recovery is the remaining way in. SP 800-63B-4 sets the floor for saved recovery codes: at least 64 bits from an approved random generator, hashed storage, the same throttling as login, invalidation on use, a replacement code and a notification. Sixteen hex characters shown in four groups carry 64 bits. ASVS 5.0 (6.5.2) requires a salted password hash such as Argon2id for lookup secrets under 112 bits.

A password reset must not switch MFA off (ASVS 5.0 6.4.3): after the emailed link, still ask for the second factor or a recovery code. Replacing a lost factor needs identity proofing as strong as enrollment (6.4.4), and every recovery must notify all the user's addresses. The password reset lab at /labs/recovery/ makes a recovery credential single-use.

## Throttle by account, address and network

Credential stuffing replays username and password pairs leaked from other sites; password spraying tries a few common passwords across many accounts. Both arrive from many IP addresses, so per-IP limits alone miss them, and hard lockouts let anyone lock a victim out. Keep failure counters per account identifier, per client address (an IPv6 host can use its whole /64), per network and overall.

When a counter trips, add friction instead of refusing outright: a bot challenge, a growing delay or risk-based checks, which SP 800-63B-4 lists as ways to avoid locking out the real user. Its hard ceiling is 100 consecutive failures per authenticator. Count failures for identifiers that don't exist, take the client IP only from a proxy header you control (ASVS 5.0 15.3.4), and alert on spikes in failures and in attempts against unknown accounts. Feed the decision with bot signals such as hosting-provider and proxy address ranges, connection fingerprints and headless-browser checks; each can be spoofed, so combine them.

Fixed windows in Redis keep each check to one round trip. `EXPIRE NX`, available since Redis 7.0, starts the window at the first failure, so later failures don't extend it.

```python
import ipaddress

# (failures allowed, window in seconds) per counter; tune from your own traffic.
LIMITS = {"acct": (10, 900), "ip": (50, 900), "net": (200, 900)}


def counter_keys(account: str, ip: str) -> dict[str, str]:
    addr = ipaddress.ip_address(ip)
    if addr.version == 6 and addr.ipv4_mapped:  # ::ffff:203.0.113.7 from dual-stack sockets
        addr = addr.ipv4_mapped
    host_bits, net_bits = (32, 24) if addr.version == 4 else (64, 48)  # an IPv6 host can use its whole /64
    host = ipaddress.ip_network(f"{addr}/{host_bits}", strict=False)
    net = ipaddress.ip_network(f"{addr}/{net_bits}", strict=False)
    return {"acct": f"fail:acct:{account}", "ip": f"fail:ip:{host}", "net": f"fail:net:{net}"}


def tripped(r, account: str, ip: str) -> list[str]:
    keys = counter_keys(account, ip)
    counts = r.mget([keys[name] for name in LIMITS])
    return [name for name, c in zip(LIMITS, counts) if int(c or 0) >= LIMITS[name][0]]


def record_failure(r, account: str, ip: str) -> None:
    pipe = r.pipeline()
    for name, key in counter_keys(account, ip).items():
        pipe.incr(key)
        pipe.expire(key, LIMITS[name][1], nx=True)  # the window starts at the first failure
    pipe.execute()
```

## Reject breached passwords without sending them anywhere

SP 800-63B-4 requires checking new passwords against a blocklist of common, expected and compromised values, comparing the whole password and telling the user why it was rejected. It also requires at least 15 characters for a password used alone, or 8 when it is always paired with a second factor, counted in Unicode code points, with no composition rules or forced rotation.

The Pwned Passwords range API checks breaches through k-anonymity: you send the first five hex characters of the password's SHA-1 hash and compare the returned suffixes locally. With the `Add-Padding` header it also returns dummy rows with a count of 0, so response size doesn't reveal the prefix. The API needs no key and has no rate limit. If it is unreachable, fall back to a local list of common passwords and alert rather than block sign-up; ASVS 5.0 (6.2.4) asks for at least the 3,000 most common at level 1.

```js
import { createHash } from "node:crypto";

// k-anonymity: only the first 5 hex characters of the SHA-1 hash leave the server.
export async function breachCount(password) {
  const hash = createHash("sha1").update(password, "utf8").digest("hex").toUpperCase();
  const res = await fetch(`https://api.pwnedpasswords.com/range/${hash.slice(0, 5)}`, {
    headers: { "Add-Padding": "true", "User-Agent": "example-app-signup" },
    signal: AbortSignal.timeout(3000),
  });
  if (!res.ok) throw new Error(`Pwned Passwords returned HTTP ${res.status}`);
  for (const line of (await res.text()).split("\n")) {
    const [suffix, count] = line.trim().split(":");
    if (suffix === hash.slice(5)) return Number(count); // padding rows carry count 0
  }
  return 0;
}

// Run on registration and password change. Returns a reason to show the user, or null.
export async function rejectPassword(password, { mfaRequired, commonPasswords, log }) {
  password = password.normalize("NFC");
  const length = [...password].length; // code points, as NIST counts them, not UTF-16 units
  if (length < (mfaRequired ? 8 : 15)) return `Use at least ${mfaRequired ? 8 : 15} characters.`;
  if (commonPasswords.has(password.toLowerCase())) return "That password is too common.";
  try {
    if ((await breachCount(password)) > 0) return "That password appears in a known data breach.";
  } catch (err) {
    log.warn({ err }, "breach check unavailable; local list only"); // fail open, but alert
  }
  return null;
}
```

## Answer identically for real and unknown accounts

Attackers enumerate accounts to aim stuffing and spraying, comparing messages, status codes, headers and response times. Return one error for a wrong password, an unknown account and a locked account. When the account doesn't exist, verify against a dummy hash with the same cost, and apply the same throttling. In a test with argon2-cffi defaults, known and unknown accounts answered in 25.8 and 25.7 ms; without the dummy hash, unknown accounts answered in 0.004 ms.

For password reset, queue the lookup and always return the same `202` response. For registration, email the address rather than saying it is taken. ASVS 5.0 (6.3.8) requires this at level 3.

```python
import secrets

from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError

from limiter import record_failure, tripped

ph = PasswordHasher()                               # Argon2id with the library defaults
DUMMY_HASH = ph.hash(secrets.token_urlsafe(32))     # same cost as a real verifier
FAILED = (401, {"error": "Incorrect email or password."})
SLOW_DOWN = (429, {"error": "Too many attempts. Complete the challenge or try again later."})


def login(users, r, email: str, password: str, ip: str):
    account = email.strip().casefold()              # count every identifier, real or not
    if tripped(r, account, ip):
        return SLOW_DOWN                            # or require a bot challenge first
    user = users.get(account)
    try:
        ph.verify(user["hash"] if user else DUMMY_HASH, password)
        ok = user is not None
    except (VerificationError, InvalidHashError):
        ok = False
    if not ok:
        record_failure(r, account, ip)
        return FAILED
    r.delete(f"fail:acct:{account}")
    return 200, {"next": "second_factor"}           # password alone is not a session


def request_password_reset(queue, email: str):
    queue.put(("send_reset_link", email.strip()))     # the worker looks the account up
    return 202, {"message": "If an account uses that address, we've emailed a reset link."}
```

## Close the paths that skip the second factor

Most MFA bypasses are design gaps rather than broken cryptography.

- A half-finished login is not a session: hold the password-verified state on the server and refuse every API call until the second factor passes.
- Mobile APIs, OAuth token endpoints, legacy protocols such as IMAP basic auth and long-lived API keys often skip MFA; give every path the same factors.
- Adding a factor or changing the phone number needs authentication at the account's highest level and a notice to existing addresses (SP 800-63B-4 4.1.2); treat email changes and new API tokens the same way.
- Never log OTPs, push secrets or recovery codes. Compare codes in constant time and accept only ASCII digits; Python's `str.isdigit()` also accepts superscripts and Arabic-Indic digits.
- 'Remember this device' cookies are bearer credentials: give them an expiry, bind them to the account and revoke them with the user's sessions.
- A session cookie stolen after login bypasses every factor, so keep sessions short and re-authenticate before sensitive actions.

## Guided practice

A consumer app is adding MFA. Review its design notes and test log, find every way an attacker could take over an account, and rewrite the notes.

### Supplied material

```text
Design notes, app.example.test
D1  POST /login returns 404 {"error":"no such user"} or 401 {"error":"wrong password"}.
D2  An account locks for 24 hours after 5 failed logins. Unknown usernames are not counted.
D3  TOTP: 6 digits, 30-second step, codes accepted from T-3 to T+3.
D4  Push: an Approve / Deny prompt, with no limit on prompts.
D5  SMS fallback is always offered. "Resend code" resets the attempt counter.
D6  Recovery: 8 codes of 6 digits, stored as unsalted SHA-256. The password-reset email link signs the user in.
D7  New passwords are checked with POST https://breach-check.example/v1 {"password": "..."}.
Test log
T1  One TOTP code submitted at 10:00:05 and again at 10:00:20: both 200.
T2  50 parallel requests with wrong codes: 50 evaluated, failure counter shows 1.
```

### 1. Remove the enumeration leaks

Explain how D1 and D2 reveal which accounts exist, and give the corrected behavior.

Worked answer: D1 differs in status and message; D2 locks only real accounts, so lockout itself is a signal. Return one `401` with a generic message, verify against a dummy hash for unknown accounts, and count failures for every submitted identifier.

### 2. Fix the TOTP verifier

Use D3, T1 and T2 to list the TOTP defects and the fixes.

Worked answer: T-3 to T+3 keeps a phished code valid for about 3.5 minutes; allow at most one step either side. T1 is a replay: store the last accepted step and accept only later steps. T2 is a race: charge the attempt atomically before checking the code, and claim the step with a conditional update.

### 3. Fix push and SMS

Explain what is wrong with D4 and D5 under SP 800-63B-4 and correct both.

Worked answer: Approve-or-deny push is no longer acceptable and invites MFA fatigue: make the user type a code from the login screen and cap prompts since the last successful login. Generating a new code must not reset the failure count. SMS is restricted, so offer a passkey or TOTP alternative and warn users of the risk.

### 4. Fix recovery

Assess D6 and rewrite the recovery design.

Worked answer: Six digits carry about 20 bits, and unsalted SHA-256 hashes of them are reversed instantly. Use at least 64 random bits per code, a salted password hash, single use, throttling and a notification. The reset link must not sign the user in or skip the second factor.

### 5. Fix lockout and the breach check

Explain the risks in D2 and D7 and propose replacements.

Worked answer: A hard 24-hour lock lets anyone lock out a victim; use growing delays, bot challenges and step-up, with SP 800-63B-4's ceiling of 100 consecutive failures. D7 sends the plaintext password to a third party; use a k-anonymity range query that sends only a five-character hash prefix, with padding.

Deliverable: Corrected design notes D1 to D7, each with one sentence naming the requirement it now meets.

Scope: Paper exercise: the fixture is complete. You can reproduce T1 and T2 with the `totp.py` verifier and its tests from this guide.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [NIST SP 800-63B-4 (July 2025), 3.1.3: out-of-band devices, push approval and PSTN rules](https://pages.nist.gov/800-63-4/sp800-63b.html#out-of-band)
- [NIST SP 800-63B-4 (July 2025), 3.2.2: rate limiting (throttling)](https://pages.nist.gov/800-63-4/sp800-63b.html#throttle)
- [RFC 6238: TOTP, section 5.2 validation and time-step size](https://www.rfc-editor.org/rfc/rfc6238.html#section-5.2). May 2011.
- [OWASP ASVS 5.0.0, V6 Authentication](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x15-V6-Authentication.md). Version 5.0.0 (May 2025).
- [W3C Web Authentication Level 3: verifying an authentication assertion](https://www.w3.org/TR/webauthn-3/#sctn-verifying-assertion). W3C Recommendation, 25 August 2026.
- [CISA: Implementing Number Matching in MFA Applications (October 2022)](https://www.cisa.gov/sites/default/files/publications/fact-sheet-implement-number-matching-in-mfa-applications-508c.pdf)
- [Uber: security update on the September 2022 incident](https://www.uber.com/us/en/newsroom/security-update/)
- [Have I Been Pwned API v3: Pwned Passwords range search and padding](https://haveibeenpwned.com/API/v3#PwnedPasswords)
- [OWASP Credential Stuffing Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html)
- [OWASP Authentication Cheat Sheet: authentication and error messages](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#authentication-and-error-messages)