Security Engineering · Authentication
Factor Independence and the Paths That Bypass MFA
Why the assurance of two factors is the product of their independence, and how fallback, enrolment, and recovery paths quietly collapse a two-factor system to one.
Two factors are worth more than one only if compromising one does not compromise the other. That statement is trivial and it is the one most MFA deployments violate, not by choosing bad factors but by adding paths that reach the same account without them. A system with a password, a TOTP app, and an email-based password reset has one factor, because the reset path converts control of an email inbox into a new password and the TOTP is never consulted.
The arithmetic of independence
Assurance is usually described as a product. If an attacker’s probability of defeating factor A is $p_A$ and of defeating factor B is $p_B$, and the two are independent, the probability of defeating both is $p_A p_B$. If they are not independent, the product does not hold, and the true value is the probability of the easiest joint path.
Independence fails in three recognisable ways. The factors can share a compromise channel, as when both the password and the OTP secret are stored in the same password manager and the manager is breached. They can be defeated by a single real-time attack, as when a proxy relays both a password and a TOTP code within the code’s thirty-second step. And they can be bypassed by a path that requires neither, which is the recovery-path case and the one that is easiest to overlook because it lives in a different codebase from the login flow.
The real-time relay case is worth separating from the others because it defeats factors that are genuinely independent in the storage sense. A TOTP code is a bearer value that travels through the same channel as the password, so a party controlling that channel obtains both. The mechanics of the code and its window are covered in TOTP time steps; the relevant fact here is that the code’s validity is not bound to the client that requested it. Origin-bound credentials change this because the signed challenge includes the origin, which is what makes the WebAuthn ceremony resist a relay.
NIST SP 800-63B formalises the same idea through authenticator types and their combinations. A memorised secret, a look-up secret, an out-of-band device, a single-factor OTP device, a multi-factor cryptographic software or hardware authenticator: each has a defined strength, and the combinations that satisfy a given assurance level are enumerated rather than left to intuition. The point of the enumeration is that some combinations look like two factors and count as one: two memorised secrets, such as a password and a security question, are two instances of the same factor type and are not treated as multi-factor at all.
The downgrade surface
An attacker does not have to break the strongest factor. They need to find the weakest path to the same authority, and MFA systems are full of them.
The first is the fallback offered when the second factor is unavailable. A prompt that says the code could not be delivered and offers to send it by SMS instead converts a hardware token into the security of the SIM, which is a well-documented weak channel. A prompt that offers to disable the second factor after a password confirmation converts two factors into one at the moment the user is most likely to be under attack, which is exactly when an attacker is motivated to trigger it. The correct handling is that removing or replacing a factor requires proof of an existing factor and a delay long enough for the account owner to notice, delivered through a channel that is not the one being changed.
The second is enrolment. If a factor can be added to an account with only the password, then the password is the only factor that protects the account’s factors, and an attacker with the password adds their own authenticator and locks the owner out. Enrolment must require a fresh proof of an existing factor, and adding a factor should invalidate or notify on the others.
The third is recovery, and it is usually the weakest. An email reset link converts control of an inbox into control of the account. If the inbox is protected by the same password, the factors are not independent; if it is protected by nothing, the account’s effective strength is the inbox’s. A reset that bypasses the second factor entirely means the second factor is optional for anyone who can reach the reset form.
The fourth is the API surface. A mobile application, a legacy endpoint, an internal service, or a partner integration that authenticates with the password alone is a path to the same authority, and it does not appear in the web login flow that was hardened. The resource owner password credentials grant in RFC 6749 is the standardised form of this mistake: it moves the password into a protocol message between two servers, where the second factor has no place to go. Enumerating every path that can produce a session for an account is the necessary first step, and it is a discovery problem rather than a design problem: the paths exist because they were built at different times by different people.
The fifth is the session. A session established after a successful MFA is a bearer credential, and its theft bypasses every factor. This is why the session’s lifetime, scope, and rotation matter as much as the login ceremony; session identifier rotation is an MFA control in the sense that it bounds how long a stolen post-MFA session remains usable.
Making the factor decision a single place
The structural remedy for the downgrade surface is to stop treating authentication as a sequence of checks in a handler and treat it as a policy evaluated in one place. Each request carries a session; the session records which authenticators were satisfied, when, and for what purpose; and the policy decides whether that record is sufficient for the operation being attempted.
Modelling the record explicitly is what makes the gaps visible. A session that says “password verified at T, TOTP verified at T” is a different object from one that says “password verified at T”, and a policy that requires the second for a given operation can refuse the first. A session that says “recovery link consumed at T” should be distinguishable from a normal login, and should carry a restriction that the policy enforces.
from dataclasses import dataclass
from enum import Enum
class Method(Enum):
PASSWORD = 1
TOTP = 2
WEBAUTHN = 3
RECOVERY_LINK = 4
@dataclass(frozen=True)
class Session:
methods: frozenset[Method]
authenticated_at: float
recovery: bool = False
def permits(session: Session, operation: str, now: float) -> bool:
if operation == "read_profile":
return bool(session.methods) and now - session.authenticated_at < 3600
if operation == "change_password" or operation == "enrol_factor":
# a recovery session must never be able to rewrite the factors
return (not session.recovery
and Method.WEBAUTHN in session.methods
and now - session.authenticated_at < 300)
return False
Two properties in that sketch do the work. The recovery flag is a restriction rather than an alternative credential, so a session created through the reset path cannot be used to change the factors that the reset path bypassed. And the freshness bound on the sensitive operations is short, which forces a re-authentication rather than allowing a session established hours ago to authorise a factor change. A re-authentication is only meaningful if it produces a new session identifier and a new timestamp, which is the rotation argument again.
Freshness is a dimension that is easy to omit. A session that satisfied MFA at login and has been idle for a week is not the same assurance as one that satisfied it a minute ago, and a policy that checks only “was MFA satisfied” treats them identically. Recording the timestamp and comparing it against a per-operation maximum age is cheap and it converts a binary check into a graded one.
Recovery as a designed path rather than an exception
Recovery cannot be removed. Users lose devices, and a system that cannot restore access loses the user. What can be removed is the assumption that recovery is exempt from the policy.
The design that holds together treats recovery as an authentication event with its own method and its own restrictions. A recovery code is a single-use bearer credential, so it should be stored hashed, like a password, and its use should be visible to the account owner. A reset link should be single-use, short-lived, and bound to the account and to the request, so that a link issued for one purpose cannot be replayed for another. A recovery session should be time-limited, should be flagged, and should not be able to modify the factors that protect the account until a normal authentication has occurred.
The channel used for recovery determines the account’s real strength. If the recovery channel is email, then the account is as strong as the email account, and the email account’s own recovery channel determines that. The chain terminates at whichever account has the weakest recovery, and the honest way to reason about it is to follow the chain rather than to assume it stops at the first factor.
NIST SP 800-63B’s treatment of reauthentication and of the binding between an authenticator and its enrolment is the specification-level version of this. The relevant requirement is not that recovery be impossible but that it be a distinct, recorded, and restricted event, so that the assurance of a session reflects how it was established.
Measurement: what to count
A deployment that wants to know whether its MFA is real has to count the paths rather than the users. The useful quantities are the number of ways a session can be created for an account, the number of those ways that require more than one factor type, and the number of accounts for which each path is reachable.
The second number is usually smaller than expected. Password-only API endpoints, legacy protocols that predate the factor, support tooling that can impersonate, and the recovery flow itself all tend to appear on the list. Each is a legitimate business requirement and each is a path that an attacker will find. The decision for each is whether to require the factor, to restrict the path to a network or a role, or to remove it.
The third number is the one that turns a policy into a risk statement. If a path exists and is reachable for every account, the account’s assurance is the assurance of that path. If it is reachable only for a subset, the subset’s assurance is lower and the population should be known.
The other measurable quantity is time. A factor’s protection decays with the age of the session it produced, with the age of the credential, and with the delay before a compromise is detected. Bounding the session lifetime and the re-authentication interval converts an unbounded exposure into a bounded one, and it is a control that costs nothing to reason about and is often omitted because it is not part of the login flow.
The limit of the whole construction
Every authentication factor ultimately produces a session, and the session is a bearer credential. A password, a TOTP code, a WebAuthn assertion, a recovery link: each is a way to obtain one, and once obtained, the session’s authority is the account’s authority until it expires or is revoked. The strength of the factors determines how hard it is to obtain the session; the session controls determine what a stolen session can do and for how long. Both halves have to hold, and the second half is the one that a focus on login ceremonies tends to leave unexamined.
That is also why the weakest-path argument is the right way to read an MFA deployment. The question is never whether the strongest path is strong. It is whether every path that reaches the account is at least as strong as the policy claims, and whether the paths that are not are known, restricted, and counted.
References
- SP 800-63B: Digital Identity Guidelines, Authentication and Lifecycle Management — NIST
- RFC 6749: The OAuth 2.0 Authorization Framework — IETF
- OWASP Multifactor Authentication Cheat Sheet — OWASP Foundation
- OWASP Credential Stuffing Prevention Cheat Sheet — OWASP Foundation