Security recipes · 09
MFA replay & fatigue
- The threat
- Reuse an observed or phished one-time code within its validity window, or wear the user down with repeated approval prompts until they let the attacker in.
- Sentinel's counter
- Accepted TOTP steps must strictly advance (same-window replay fails), recovery codes are single-use digests consumed transactionally, and step-up is triggered by deterministic risk signals rather than approval prompts an attacker can spam.
- Capability
- /docs/authentication/

The attack
The second factor has its own attack surface, and it comes in two shapes.
Replay. A TOTP code is valid for its 30-second step — plus the drift window every server allows. A code read over a shoulder, captured by a phishing page relaying credentials in real time, or scraped from a screen share is a currently valid code. If the server will accept the same code twice, the attacker doesn’t need the seed; they need twenty seconds. Naive implementations verify the code against the current window and stop there — the legitimate user logs in, and so does the attacker, with the very same six digits.
Fatigue. Push-approval MFA converted “prove you have the device” into “tap the button to make the notification go away”. Attackers with a stolen password trigger login after login — 2 a.m., mid-meeting, mid-drive — until one prompt gets approved by reflex or exhaustion. The factor’s security now rests on the user’s patience, and the attacker has more of it.
How Sentinel counters it
The accepted step must strictly advance. Totp.Verify checks a code with the
RFC-recommended ±1 step drift window — and its own documentation warns that
verification alone is not enough. LoginService enforces the rest: after a code
verifies, the step it belongs to is consumed through
ISessionStore.TryAdvanceTotpStepAsync, which records the enrollment’s
LastAcceptedStep and returns false for that step or any earlier one. The same code
presented twice — same window, drift window, doesn’t matter — fails the second time,
and so does any older code. The unit test says it plainly:
Same_totp_code_cannot_complete_two_logins. Comparison of candidate codes is
constant-time, and the pending state between password and code is a single-purpose
signed token (typ: "mfa+sentinel") that only the MFA-verify endpoint accepts — an
access token cannot stand in for it
(Access_token_cannot_stand_in_for_mfa_pending_token).
Recovery codes spend like banknotes, not passwords. RecoveryCodes generates
human-typable codes from an unambiguous alphabet (no 0/O, no 1/I/L — they
get read off paper), stores only SHA-256 digests, and the store contract requires a
verified code to be deleted in the same transaction that accepts it. One use each
(Recovery_code_completes_step_up_once); regenerating the set invalidates every
previous code. A photographed recovery sheet is ten logins, not unlimited ones — and
each use is an auditable event.
Step-up is risk-gated, so there is nothing to spam. Sentinel deliberately has no
push-approval factor — no button whose accidental tap is a login. Instead, the
risk engine scores each attempt on deterministic
signals (new device, impossible travel, IP reputation, velocity) and maps the score to
act: at the step-up threshold (default 40) the login must complete a knowledge-bound
challenge — TOTP or a passkey ceremony — and at the block threshold it stops outright,
emitting risk.stepup / risk.blocked with the contributing signals recorded. An
attacker hammering a stolen password doesn’t generate prompts on the victim’s phone;
they generate rising risk scores and abuse-layer counters on their own attempts.
The upgrade path is phishing-resistant. For the real-time-relay phish — the one attack TOTP structurally cannot beat, since the user willingly types the code into the wrong page — the answer is passkeys: origin-bound credentials where the browser, not the user, decides whom to authenticate to. Passkey logins mark the session’s MFA level as phishing-resistant, and that assurance is minted into the token for your app to demand where it matters.
What your app must still do
- Don’t retry MFA submissions. A failed verify is an answer, not a timeout. A client that auto-resubmits the same code will hit the anti-replay wall and confuse the user; return them to the prompt for the next code.
- Show recovery codes once, at enrollment, and never again. Sentinel stores digests — it cannot re-display them. Your UI should present the set a single time with a download/print affordance and an explicit “these are gone after this screen”.
- Drive passkey adoption for the accounts that matter. Enrollment UX is your
surface; every admin on a passkey is one less real-time-phishable factor. Require
phishing-resistant assurance (the
mfaclaim) for privileged routes. - Keep server clocks disciplined. Anti-replay tightens the timing contract: a self-hosted deployment drifting minutes will reject honest codes. NTP is part of the MFA deployment.
- Educate on seed hygiene. The TOTP secret in a screenshotted QR code or a synced note is a durable bypass no step counter can see. Enrollment guidance is a security control.