MFA Enforcement Patterns: Phishing-Resistant, Adaptive & Step-Up Auth
A second factor only reduces risk if it is enforced at the right moments, resists the attacks that actually target it, and is bound to the session it protects. Most production breaches involving “MFA-enabled” accounts do not defeat the cryptography — they defeat the enforcement: a push prompt approved under fatigue, a one-time code relayed through a phishing proxy, or a recovery flow that quietly bypasses the second factor entirely. This guide is part of Secure Authentication & Session Architecture and treats MFA as an architectural control mapped to NIST SP 800-63B assurance levels, OWASP ASVS V2.8, and SOC 2 CC6.1 rather than a checkbox. It covers method selection across phishing-resistant WebAuthn/FIDO2, TOTP, push, and deprecated SMS; adaptive and step-up challenges on sensitive operations; and how the resulting assurance is carried in the session. Where OAuth 2.0 & OpenID Connect implementation governs how identity tokens are issued and session fixation prevention governs how a session identifier is rotated, MFA enforcement governs the strength of the proof that a human — not a stolen credential — is present at each privileged edge.
Threat Anatomy
MFA is deployed to defeat credential compromise, but the attacker’s response has moved up the stack: rather than cracking the factor, adversaries target the human approving it and the channel carrying it.
Attacker perspective. After harvesting a password through a breach dump or infostealer, an attacker faces the second factor. Four techniques dominate. In MFA fatigue (push-bombing), the attacker triggers login repeatedly, flooding the victim’s phone with approval prompts until one is tapped out of annoyance or confusion. In a real-time phishing proxy attack, a reverse-proxy toolkit such as Evilginx sits on a look-alike domain, relays the victim’s password and one-time code to the real site in real time, and captures the resulting session cookie — defeating any factor that lacks origin binding. In a SIM swap, the attacker social-engineers the mobile carrier into porting the victim’s number, intercepting every SMS code. In consent and token theft, the attacker skips the factor entirely by abusing a recovery flow, a legacy protocol endpoint, or an over-permissive refresh token.
MITRE ATT&CK coverage. Push-bombing and proxy relay map to T1621 (Multi-Factor Authentication Request Generation) and the broader abuse of the authentication mechanism. SIM-swap and code interception map to T1111 (Multi-Factor Authentication Interception). Both frequently follow T1078 (Valid Accounts) once the first factor is compromised.
Real-world impact. Every major MFA-bypass incident of the last several years reduces to one of these vectors: a proxy that relayed a code, a fatigue attack that harvested one approval, or a recovery path that never asked for the factor at all. The defensive conclusion is unambiguous — only origin-bound, phishing-resistant authenticators break the real-time proxy and fatigue classes simultaneously, and enforcement must extend to every path that can mint an authenticated session, including recovery.
MFA Threat Reference
| Threat | Attacker Technique | MITRE ATT&CK | Defeats Which Factors | Primary Control |
|---|---|---|---|---|
| Push-bombing / fatigue | Flood approval prompts until one is tapped | T1621 | Tap-to-approve push | Number matching, rate limits, WebAuthn migration |
| Real-time phishing proxy | Relay password + OTP through look-alike domain (Evilginx) | T1621 | TOTP, push, SMS, email OTP | Origin-bound WebAuthn/FIDO2 |
| SIM swap | Port victim number, intercept codes | T1111 | SMS, voice-call OTP | Retire SMS; use TOTP or WebAuthn |
| SS7 / OTP interception | Intercept messaging channel | T1111 | SMS, email OTP | Bound authenticators, encrypted secrets |
| Recovery-path bypass | Reset factor via weak backup flow | T1078 | All, if recovery is weaker | Enforce MFA on recovery, single-use codes |
Prerequisites & Scope
Before applying the enforcement patterns below, confirm these preconditions:
- Assurance inventory: Every protected operation is classified by the assurance it demands — public read, standard authenticated action, or high-value action (payments, admin, credential change). This drives the AAL mapping in Step 1.
- Verified enrollment path: A second factor is only enrolled inside an already-authenticated, recently-reauthenticated session. Enrollment endpoints are not reachable with a password alone.
- Session infrastructure: The application issues server-side sessions (or signed, revocable tokens) that can carry an MFA claim. Rotating the session identifier after a successful challenge requires the mechanics covered in session fixation prevention.
- HTTPS and a stable origin: WebAuthn requires a secure context and a fixed relying-party ID. Subdomains, staging hosts, and native app schemes must be planned before enrollment, because the rpID is baked into every stored credential.
- Secret storage: A KMS or envelope-encryption facility exists to encrypt TOTP shared secrets and hashed backup codes at rest. Plaintext secrets in the database defeat the entire control.
- Framework assumptions: Examples use Node.js/TypeScript with
@simplewebauthn/serverandotplib. Adapt the ceremony and drift-window logic to your platform’s equivalents.
Mitigation Architecture
The enforcement model is a decision gate, not a login toggle. A first-factor success produces a partially authenticated session that can do nothing sensitive. A risk signal — new device, elevated operation, aged assurance — triggers a step-up challenge. Only an origin-bound challenge success upgrades the session and stamps it with an MFA claim recording the method and assurance level reached.
Method Comparison by Assurance
| Method | Phishing-Resistant | SIM-Swap Exposed | Fatigue Surface | Typical AAL |
|---|---|---|---|---|
| WebAuthn / FIDO2 (passkey, security key) | Yes — origin-bound | No | None | AAL2–AAL3 |
| TOTP (RFC 6238 authenticator app) | No | No | None (no prompt) | AAL2 |
| Push with number matching | Partial | No | Reduced | AAL2 |
| Push tap-to-approve | No | No | High | AAL2 (discouraged) |
| SMS / voice OTP | No | Yes | None | Restricted — fallback only |
Step-by-Step Implementation
The step order below assumes the assurance inventory and verified-enrollment path from the preceding section are already established. Each step names the ASVS or NIST control it satisfies.
Step 1 — Select Methods per Assurance Level (ASVS V2.8.1, NIST SP 800-63B AAL2/AAL3)
Map every protected operation to an assurance level, then bind that level to permitted methods. AAL2 requires two distinct factors; AAL3 requires a hardware-backed, phishing-resistant authenticator with verifier impersonation resistance — in practice, WebAuthn with a resident credential and user verification.
type AAL = 'aal1' | 'aal2' | 'aal3';
type Method = 'webauthn' | 'totp' | 'push_number_match' | 'sms';
// Which methods are allowed to *satisfy* a given assurance level.
const METHODS_FOR_AAL: Record<AAL, Method[]> = {
aal1: ['webauthn', 'totp', 'push_number_match', 'sms'],
aal2: ['webauthn', 'totp', 'push_number_match'], // SMS excluded
aal3: ['webauthn'], // phishing-resistant only
};
// Which assurance level each operation demands.
const OP_REQUIRED_AAL: Record<string, AAL> = {
'read:profile': 'aal1',
'update:email': 'aal2',
'create:payment': 'aal2',
'rotate:api-key': 'aal3',
'admin:impersonate': 'aal3',
};
export function methodSatisfies(op: string, method: Method): boolean {
const required = OP_REQUIRED_AAL[op] ?? 'aal2';
return METHODS_FOR_AAL[required].includes(method);
}
SMS never appears above AAL1. Treat it strictly as a fallback for accounts that cannot yet enroll a stronger factor, and drive those accounts toward TOTP or WebAuthn through campaigns.
Step 2 — Enroll a Second Factor with Proof of Possession (ASVS V2.8.4, NIST IA-2)
Enrollment must occur inside a session that was itself authenticated and, for high-value accounts, recently re-challenged. The two flows differ fundamentally: WebAuthn registers a public-key credential and stores no secret the server could leak, while TOTP provisions a shared secret that must be encrypted at rest. Deep implementations live in the companion guides — implementing WebAuthn/FIDO2 in TypeScript and the TOTP enrollment and verification flow.
// Enrollment gate: never enroll a factor from a password-only session.
import { requireFreshAuth } from './auth-guards';
export async function beginEnrollment(session: Session, method: Method) {
// Reauthentication window: the user proved a factor in the last 5 minutes.
requireFreshAuth(session, { maxAgeSeconds: 300 });
if (method === 'webauthn') {
return startWebAuthnRegistration(session.userId); // generateRegistrationOptions
}
if (method === 'totp') {
return startTotpEnrollment(session.userId); // secret + otpauth URI + QR
}
throw new Error(`Enrollment not supported for method: ${method}`);
}
Step 3 — Enforce and Step Up on Risk Signals (ASVS V2.8.6, SOC 2 CC6.1)
Enforcement is per-operation, not per-login. Evaluate the session’s current assurance against the operation’s requirement; if it falls short — because the session is only partially authenticated, the assurance has aged out, or the operation demands a higher level — issue a step-up challenge rather than allowing the action.
interface MfaClaim {
method: Method;
aal: AAL;
verifiedAt: number; // epoch seconds
}
interface RiskContext {
newDevice: boolean;
impossibleTravel: boolean;
operation: string;
}
// Returns null if the session already satisfies the operation, else the AAL to challenge for.
export function stepUpRequired(claim: MfaClaim | null, ctx: RiskContext): AAL | null {
const required = OP_REQUIRED_AAL[ctx.operation] ?? 'aal2';
const MAX_AGE = required === 'aal3' ? 300 : 3600; // fresher proof for high-value ops
if (!claim) return required;
if (rank(claim.aal) < rank(required)) return required;
if (Date.now() / 1000 - claim.verifiedAt > MAX_AGE) return required;
if (ctx.newDevice || ctx.impossibleTravel) return required;
return null;
}
const rank = (a: AAL) => ({ aal1: 1, aal2: 2, aal3: 3 })[a];
On a successful challenge, rotate the session identifier and write the MfaClaim server-side. Binding the claim to the session — not to a long-lived token the client holds — is what makes a stolen bearer token insufficient for a sensitive operation.
Step 4 — Log and Monitor MFA Events (ASVS V2.8.7, SOC 2 CC7.2)
Every challenge outcome is a security signal. Emit structured events for enrollment, challenge issuance, success, failure, and explicit denial, then alert on the patterns that indicate fatigue or proxy attacks.
type MfaEventType =
| 'mfa.enroll' | 'mfa.challenge.issued'
| 'mfa.success' | 'mfa.failure' | 'mfa.denied';
export function emitMfaEvent(e: {
type: MfaEventType; userId: string; method: Method;
ip: string; deviceId: string;
}) {
logger.info(JSON.stringify({ ...e, ts: Date.now() }));
}
// Alert rule (pseudocode for your SIEM):
// >= 5 mfa.challenge.issued for one user in 60s -> possible push-bombing
// mfa.success from an IP/ASN never seen for user -> possible proxy relay
// mfa.denied burst -> user is rejecting attacker prompts
A burst of mfa.denied events is often the clearest early warning of an active push-bombing campaign — the victim is repeatedly rejecting prompts they did not initiate.
Edge Cases & Bypass Patterns
Recovery Flow That Skips the Factor
The most common real-world bypass is not against the factor but around it: a password-reset or account-recovery flow that re-establishes an authenticated session without re-proving MFA. Enforce the same AAL on recovery as on login, require possession of a single-use backup code, and rotate the session on completion. A recovery path weaker than the login path sets the account’s true assurance to the weaker of the two.
Aged Assurance on Long-Lived Sessions
A session authenticated at AAL2 weeks ago should not silently authorize a payment today. Without a freshness window, a stolen session cookie carries full assurance indefinitely. Stamp verifiedAt on the MFA claim and re-challenge when it exceeds the operation’s maximum age, as shown in Step 3.
Number-Matching Defeated by Social Engineering
Number matching resists blind approval, but an attacker on a live phishing call can read the displayed number to the victim and ask them to enter it. Number matching raises the bar; it does not close the proxy vector. High-value operations must fall back to origin-bound WebAuthn, which has no number to relay.
TOTP Code Replay Within the Window
A TOTP code is valid for a 30-second step plus any drift window. A real-time proxy can replay a captured code inside that window unless the verifier consumes each code exactly once. Track the last accepted counter per user and reject any code at or below it, as detailed in the TOTP enrollment and verification flow.
WebAuthn Signature Counter Regression
A cloned authenticator can be detected when its signature counter fails to advance. If the stored counter is greater than or equal to the counter in a fresh assertion, treat it as a possible clone and fail closed. Authenticators that always report zero are an accepted exception, not a reason to skip the check.
Automated Testing & CI Validation
Integration Test: Step-Up Is Enforced
import { describe, it, expect } from 'vitest';
import { stepUpRequired } from './mfa';
describe('MFA step-up enforcement', () => {
it('challenges a partial session for a sensitive operation', () => {
const req = stepUpRequired(null, { newDevice: false, impossibleTravel: false, operation: 'create:payment' });
expect(req).toBe('aal2');
});
it('forces AAL3 for key rotation even from an AAL2 session', () => {
const claim = { method: 'totp' as const, aal: 'aal2' as const, verifiedAt: Date.now() / 1000 };
expect(stepUpRequired(claim, { newDevice: false, impossibleTravel: false, operation: 'rotate:api-key' })).toBe('aal3');
});
it('re-challenges when assurance has aged out', () => {
const claim = { method: 'webauthn' as const, aal: 'aal3' as const, verifiedAt: Date.now() / 1000 - 4000 };
expect(stepUpRequired(claim, { newDevice: false, impossibleTravel: false, operation: 'rotate:api-key' })).toBe('aal3');
});
});
CI/CD Gate: Enforce Method Policy and Ban SMS-Only
# .github/workflows/mfa-gate.yml
name: MFA Enforcement Gate
on: [push, pull_request]
jobs:
policy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Unit tests for step-up policy
run: npm test -- --testPathPattern=mfa
- name: Semgrep — ban SMS as sole factor for AAL2+
uses: semgrep/semgrep-action@v1
with:
config: |
rules:
- id: sms-only-mfa
pattern: enforceMfa($OP, ["sms"])
message: "SMS must not be the sole factor for AAL2+ operations"
severity: ERROR
languages: [typescript, javascript]
- id: plaintext-totp-secret
pattern-either:
- pattern: db.save({ totpSecret: $S })
- pattern: user.totpSecret = $S
message: "TOTP secret must be encrypted before persistence"
severity: ERROR
languages: [typescript, javascript]
Compliance Mapping
| Framework | Control | Satisfied By |
|---|---|---|
| OWASP ASVS v4.0 | V2.8.1 | Method-to-AAL mapping; multiple distinct factors offered |
| OWASP ASVS v4.0 | V2.8.4 | Enrollment only from a freshly authenticated session |
| OWASP ASVS v4.0 | V2.8.6 | Step-up challenge on sensitive operations and elevated risk |
| OWASP ASVS v4.0 | V2.8.7 | Structured MFA event logging and anomaly alerting |
| SOC 2 | CC6.1 | Logical access enforced by assurance-mapped MFA at each edge |
| SOC 2 | CC7.2 | Monitoring of MFA challenge, failure, and denial events |
| NIST SP 800-63B | AAL2 | Two distinct factors; SMS restricted; replay protection |
| NIST SP 800-63B | AAL3 | Phishing-resistant, hardware-backed authenticator with verifier impersonation resistance |
| NIST SP 800-53 Rev. 5 | IA-2(1) | Multi-factor authentication to privileged and non-privileged accounts |
| ISO 27001 | A.9.4.2 | Secure log-on procedures with adaptive re-authentication |
Common Pitfalls Checklist
Frequently Asked Questions
Why is WebAuthn phishing-resistant when TOTP is not?
WebAuthn signs a server-issued challenge with a private key that never leaves the authenticator, and the signature is cryptographically scoped to the relying-party ID (the origin). A real-time phishing proxy hosted on a look-alike domain receives an assertion bound to the wrong origin, which the legitimate verifier rejects. TOTP, by contrast, produces a 6-digit code with no origin binding — a proxy simply forwards the code to the real site within its validity window. This origin binding is why AAL3 in NIST SP 800-63B effectively requires WebAuthn-class authenticators.
How do I stop MFA fatigue push-bombing attacks?
Replace tap-to-approve push with number matching so a prompt cannot be approved without information only the legitimate login screen shows. Rate-limit challenge issuance per account, apply exponential back-off, and alert your SIEM on bursts of denied requests. These measures reduce the attack surface, but the durable fix is migrating high-value operations to phishing-resistant WebAuthn, which presents no approval prompt to spam in the first place.
Is SMS-based MFA still acceptable?
Only as a last-resort fallback. NIST SP 800-63B classifies SMS as a restricted authenticator because SIM-swap and SS7 interception (MITRE T1111) allow an attacker to receive the code without the victim’s device. SMS is still better than a password alone, so it has a role for users who cannot yet enroll a stronger factor, but it must never be the sole option for AAL2 or AAL3 operations and should be paired with active migration toward TOTP or WebAuthn.
Where should the MFA state be recorded — token or session?
Bind the completed challenge to the authenticated session as a server-side claim recording the method used, the assurance level reached, and a timestamp. Re-evaluate that claim on each sensitive operation so a step-up is triggered when the assurance has aged out or the operation demands a higher level. Storing assurance only in a client-held token means a stolen bearer token carries full privilege; a server-side claim can be revoked and is checked fresh on every privileged call.
Related
- OAuth 2.0 & OpenID Connect Implementation — how identity tokens carrying authentication context are issued and validated
- Session Fixation Prevention — rotating the session identifier after a successful step-up challenge
- Implementing WebAuthn (FIDO2) in TypeScript — the registration and authentication ceremonies for phishing-resistant passkeys
- TOTP Enrollment and Verification Flow — RFC 6238 secret provisioning, drift windows, and replay prevention
- Secure Authentication & Session Architecture — the parent guide covering the full authentication and session control set