← All posts

We required MFA. One query parameter turned it off.

Authagonal·July 14, 2026
securitymfaoidcwar-story

We shipped multi-factor authentication. After the password, the login page challenged for a second factor, the way you would expect. We tested it, it worked, we moved on. Then, in a pre-launch pass over our own auth server, we found you could skip the whole thing by editing a URL.

Here is the shape of it. In an OIDC flow your application sends the user to /connect/authorize. If they are not signed in yet, the server bounces them to the login page with the original destination tucked into a parameter: /login?returnUrl=/connect/authorize?.... You enter your password, you clear MFA, and the login flow returns you to that returnUrl, at which point /connect/authorize issues an authorization code and the tokens follow.

The bug lived in the order of operations. The password step signed you in. That is, it set an authenticated cookie. MFA was the next page in the sequence. And /connect/authorize, the endpoint that actually hands out tokens, asked exactly one question before doing so: is this request authenticated? It never asked whether the session had cleared a second factor. "Authenticated" and "authenticated with MFA" were the same cookie.

So the bypass is not clever. You submit your password. You receive the authenticated cookie. Then, instead of following the flow to the MFA prompt, you go straight to the returnUrl yourself, /connect/authorize?..., the address the server handed you at the very start. Authorize sees an authenticated principal, runs its one check, and issues the code. The second factor never happens. The MFA prompt was a step in a hallway, and nothing stopped you walking around it.

The reason this survives testing is that the happy path is airtight. Click through the interface like a person and you get challenged every time. The gate is real. It is just in the wrong place. The security boundary in an OIDC server is not the login screen. It is /connect/authorize, the point where a session turns into tokens. Any requirement that lives only in the pages leading up to that boundary is advisory, because anyone who can make the underlying request can make it directly.

That reframing is the fix. MFA is not a step you perform. It is a property the session either has or does not. So we stopped modelling it as a page and started modelling it as a claim. When you clear the second factor, the login flow writes an mfa_authenticated marker into the auth cookie. /connect/authorize no longer accepts "authenticated." It requires "authenticated and carrying that marker." A password-only cookie is now inert at the token endpoint: it gets bounced back to finish MFA no matter what URL you point it at, because what it lacks is a claim, not a page visit.

Federation falls out of this cleanly. When a user signs in through an external identity provider that ran its own MFA, that provider vouches for the second factor, so the federated sign-in sets the same marker. SSO users are not challenged twice for something their IdP already enforced. The marker is the single source of truth, and every path that can legitimately satisfy MFA writes it.

The transferable lesson has nothing to do with MFA in particular. It is this: enforce a control at the boundary that grants the thing you are protecting, not at the interface step meant to lead there. We had the check. We had written the MFA logic, tested it, shipped it. It was simply attached to the part of the system an attacker does not have to use. Consent screens, terms acceptance, step-up for sensitive scopes: same failure mode. If the endpoint that issues the credential does not verify the condition, the screen that asks for it is a suggestion.

We caught this one before any customer hit it, in the same audit that turned up a few of its siblings. That is the argument for going over your own auth as though you were attacking it, and specifically for asking, at every endpoint that hands out something valuable, not "did the user walk the flow" but "what does this request actually prove." Ours proved a password. We were treating it as proof of two factors.

Enforcing MFA at the endpoint that mints tokens, not the page that asks for it, is the whole difference between real MFA and a suggestion. Authagonal enforces it where the credential is issued, so a hand-edited return URL goes nowhere.