HighMFA Bypass· 4 min read

One Flipped Status Code Away From a Permanent Passkey

A logistics platform gated passkey enrollment behind 2FA — but let the browser decide whether the OTP passed. So I told it the server said yes.

Target anonymized · a logistics & delivery platform

Every so often you find a bug that isn't clever at all — it's just honest. The system tells you the truth about how it makes decisions, and the truth is that it doesn't really make them at all. This was one of those. The target was the account-security surface of a logistics & delivery platform — the kind of app where a driver, a merchant, and a customer all share one identity backend, and where "add a passkey" had recently shown up as the shiny new passwordless feature. Passkeys are supposed to be the strong stuff. That's exactly why I wanted to watch how they were born.

The spark

I was poking at the profile page, specifically the "Generate passkey" flow. Before it would let you enroll a new authenticator, it demanded a second factor: pick a delivery channel (I chose "Send via Email"), receive a 6-digit OTP, type it back, confirm. Sensible. 2FA-gating a passkey enrollment is exactly what you want, because a passkey is permanent, device-bound, password-proof access.

But something about the sequencing bugged me. The OTP prompt, the confirm button, and the QR code that would eventually bind my device all lived in the same single-page flow. That's a smell. When the "prove it's really you" step and the "here's your permanent credential" step run in the same client-side state machine, the only thing standing between them is whatever the browser decides to believe.

So I asked the dumb question: who actually decides that my OTP was correct?

Digging in

First hypothesis: the OTP is weak. Maybe it's short-lived but not rate-limited, or predictable. I typed a deliberately wrong code — 000000 — and watched the network tab.

The client fired a request to an identity endpoint, something like POST /api/v1/twoFactor/verify. The server did its job perfectly: it looked at my garbage OTP and answered 400 Bad Request. Good server. Honest server. The UI showed an error and refused to move on. Dead end — brute-forcing a rate-limited 6-digit code is a losing game, and I wasn't interested in a 1-in-a-million story.

But that clean 400 was itself the tell. The server had made the correct decision. The question was whether the client cared about anything in that response other than the fact that it arrived. So I stopped attacking the OTP and started attacking the conversation. What does the front-end actually read to conclude "verified: yes"? The status line? A JSON field? A token in the body?

I set up an intercepting proxy to catch the response on its way back — not the request going out. Response manipulation is the underused half of a proxy. Everyone tampers with what they send; far fewer people rewrite what they're told.

The exploit

I re-ran the flow with my wrong OTP, let the request leave untouched, and caught the reply as it came home. The server said no. I told the browser it said yes.

# What the server actually returned:
HTTP/1.1 400 Bad Request
Content-Type: application/json

{"verified": false, "error": "invalid_otp"}

# What I forwarded to the browser instead:
HTTP/1.1 200 OK
Content-Type: application/json

{"verified": true}

That was the whole exploit. One status line, flipped. The single-page app was gating the next step on the shape of the response — a 2xx meant "second factor satisfied" — and never independently confirmed with the server that this enrollment was authorized. The UI happily advanced, rendered the passkey QR code, and let me bind the credential to my device.

The server verified the OTP correctly and then handed the security decision to the client anyway. A correct check is worthless if the code that consumes its result can be told to ignore it.

I scanned the QR with an authenticator on a device that had never touched the account, went back to the login page, chose "Sign in with passkey," and I was in. No password. No OTP. A permanent, phishing-resistant, device-bound credential — issued to the attacker.

Impact

Read the room on this one. The whole selling point of a passkey is that it survives password theft. Here, an attacker with only temporary access — a borrowed laptop, a live session, reused credentials from some unrelated dump — could mint themselves a passkey and convert that fleeting foothold into permanent ownership. The victim rotates their password? Doesn't matter. Turns on "real" 2FA? Doesn't matter; the attacker's passkey is a factor now. On a platform tying together drivers, merchants, and customer addresses, that's durable access to someone's movements, money, and home address. The 2FA gate that was supposed to make this the hard part was, functionally, a suggestion.

The fix

The root cause isn't the OTP and it isn't the passkey — it's trust placement. The client must never be the authority on whether a second factor passed.

Concretely: the OTP verification should mint a short-lived, single-use, server-side proof — bound to this user, this session, and specifically to the passkey-enrollment action. The enrollment endpoint must require that proof and re-validate it server-side before it registers a credential. If the OTP fails, no proof exists, and the enroll call is rejected on the server no matter what the browser believes. Flipping a 400 to a 200 in transit would then change a pixel, not a permission.

The lesson I keep relearning: a security check is only as strong as the least-trusted line of code that reads its result.

2fa-bypasspasskeyresponse-manipulationclient-side-trustauthenticationaccount-takeover

All identifiers, targets and payloads in this post are anonymized or defanged. Findings were reported and resolved through responsible disclosure.