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.
| 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 |
- Submit one valid TOTP code twice within the same 30 seconds, then a code from two steps ago: all but the first must fail.
- 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.
- 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.
- 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.
- 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.
- List every way to sign in (web, mobile API, OAuth token endpoint, legacy protocols, password reset) and confirm each one enforces the same factors.
- Applies to
- NIST SP 800-63B Revision 4 (final, July 2025)
- W3C Web Authentication Level 3 (Recommendation, August 2026)
- Python examples 3.11+ (3.14.7 tested) with argon2-cffi 25.1, redis-py 8.1; SQLite and PostgreSQL 18.6 tested
- Redis 7.0+ for EXPIRE NX (8.10.2 tested)
- Node.js example 18+ for global fetch (26.3.0 tested)
Login, MFA, password reset and account recovery flows. Only phishing-resistant authenticators stop real-time relay phishing.
- Part of
- Workflows: Fix a finding, Secure a feature
Learning goals and prerequisites
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.
Before you start
Developers and AppSec engineers who build or review sign-in, MFA and account recovery
- Know how your application creates a session after login.
- Read Python and JavaScript.
- Understand password hashing at a basic level.
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 |
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.
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 == 1Pin 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.
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) == 1MFA 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.
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.
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.
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.
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.
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()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.
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;
}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.
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."}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 scenario material
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.
Expected result and reasoning
D1 differs in status and message; D2 locks only real accounts, so lockout itself is a signal. Return one
401with 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.
Expected result and reasoning
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.
Expected result and reasoning
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.
Expected result and reasoning
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.
Expected result and reasoning
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.
Paper exercise: the fixture is complete. You can reproduce T1 and T2 with the totp.py verifier and its tests from this guide.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- NIST SP 800-63B-4 (July 2025), 3.1.3: out-of-band devices, push approval and PSTN rules
- NIST SP 800-63B-4 (July 2025), 3.2.2: rate limiting (throttling)
- RFC 6238: TOTP, section 5.2 validation and time-step size. May 2011.
- OWASP ASVS 5.0.0, V6 Authentication. Version 5.0.0 (May 2025).
- W3C Web Authentication Level 3: verifying an authentication assertion. W3C Recommendation, 25 August 2026.
- CISA: Implementing Number Matching in MFA Applications (October 2022)
- Uber: security update on the September 2022 incident
- Have I Been Pwned API v3: Pwned Passwords range search and padding
- OWASP Credential Stuffing Prevention Cheat Sheet
- OWASP Authentication Cheat Sheet: authentication and error messages
Reference information
- SDLC phase
- Development