A two-factor flag in the user store looks like a reassuring authorization check. It tells us the account has a second factor configured. For a sensitive operation, however, that is only half the question.
The other half is about the session in front of us: did this cookie actually complete a second-factor challenge?
Those facts can change independently. Treating them as interchangeable can silently promote an old password-only session after the account enables MFA.
Two questions that look like one
Account capability answers questions such as:
- Is a factor enrolled now?
- Could the account complete an MFA challenge?
- Has that capability since been disabled?
Session assurance answers different questions:
- Which authentication steps produced this session?
- Did the framework issue this cookie after an MFA challenge?
- Is the evidence trusted, or merely a user-supplied claim?
An enrolled account can still have a password-only session. A previously verified session can also outlive a later change to the account’s factor state. One signal cannot safely stand in for both.
The transition that exposes the gap
Snapshot tests often miss this because the final state looks correct. The account has MFA enabled, the user is authenticated, and a policy succeeds.
Now test the transition instead:
- Sign in with a password and receive a normal application cookie.
- Enable MFA for the account without replacing that cookie.
- Use the original cookie against a sensitive operation.
If authorization checks only the current enrolment flag, step three may succeed. Nothing about the original authentication ceremony changed, but the session has effectively been upgraded by a later database write.
That is the important boundary: changing account capability must not rewrite the history of an already-issued session.
Use two independent signals
A generalized policy can be expressed like this:
if (!session.IsAuthenticated || !session.HasTrustedMfaEvidence)
return Deny;
if (!await accountStore.IsMfaStillEnabled(userId))
return Deny;
return Allow;
Enter fullscreen mode Exit fullscreen mode
The first check is session evidence. In ASP.NET Core Identity, use the authentication evidence produced by the framework after a real second-factor flow. Do not accept an arbitrary client value as proof.
The second check is live account state. It confirms that the capability remains valid now.
Together they establish a stronger statement: this session completed MFA, and the account is currently MFA-enabled.
Why neither signal is enough alone
Checking only the account store prevents access by accounts with no enrolled factor, but it can silently upgrade old password-only cookies.
Checking only the cookie proves how the session was created, but it can preserve sensitive access after the factor is disabled. A long-lived claim is a cache. It needs an explicit freshness and revocation story.
The signals close different gaps:
- Session evidence prevents capability changes from rewriting authentication history.
- Current account state provides immediate revocation semantics for the protected boundary.
For particularly high-risk actions, there may be a third question: how recently did MFA occur? “MFA happened during this session” is not always equivalent to step-up authentication performed moments ago.
The engineering trade-off
A live store check is not free. It adds a query, a dependency on the identity store, and another failure mode to authorization. In server-rendered UI, the same policy may also be evaluated several times during one render, so careless use of a shared data context can introduce concurrency problems.
The answer is not to weaken the boundary. Scope it deliberately.
- Apply the stronger policy to genuinely sensitive operations, not every page.
- Reject anonymous and password-only sessions before opening a store scope.
- Use a data-access lifetime that is safe for concurrent policy evaluation.
- Fail closed when the account is missing or the authoritative check cannot establish the required state.
- Measure the added read load before deciding whether a short cache is justified.
A cache can be reasonable, but then its maximum stale window becomes part of the security contract. Write that duration down and test it.
Test the journey, not a fabricated principal
The most useful regression test exercises the real authentication path:
- Submit a valid password.
- Keep the cookie the application actually issues.
- Enable MFA through the account store.
- Call the protected operation with the original cookie.
- Assert that access remains denied.
Then add the positive journey: complete the real second-factor challenge, receive the framework-issued session, and prove the protected operation succeeds.
Focused negative tests should also cover a disabled factor, a deleted account, missing session evidence, and an unavailable store. A fast-path test can verify that a password-only session is rejected before any database query is opened.
Practical takeaway
Whenever authorization depends on MFA, write down the exact proposition each signal proves. Account capability, session assurance, and authentication freshness are separate concepts. Model them separately, combine them only at the sensitive boundary, and test the state transition that could otherwise blur them together.
What does your most sensitive policy actually prove about the session making the request?