
Auditing a WebAuthn Login Flow for AitM Proxy Phishing and Session Token Theft
A September 2026 Hacker News write-up reported that attackers used passkey phishing to hijack Microsoft cloud accounts and exfiltrate data. That reads like a contradiction. Passkeys are the control we have spent years telling developers to ship for their phishing resistance. My position: the headline is right, and the mental model it breaks was never accurate. This post is a hands-on audit of a WebAuthn login flow — why an AiTM proxy still walks away with a session token, which checks expose that in your own tenant, and which defenses actually change the outcome. Passkeys defeat credential replay. They do not defeat a proxy relaying a live, already-authenticated session, and most WebAuthn deployments I look at stop caring the moment the credential check passes.
One caveat up front. The public report is a news write-up with thin technical detail — it names the outcome, not the mechanism, tooling, or tenant configuration. Below, spec behavior comes from the W3C and IETF documents, commands and output come from a lab tenant I control, and anything about this campaign is inference unless I label it otherwise.
What WebAuthn and Passkeys Actually Protect
Origin binding stops lookalike domains, not relays
A ceremony is short. The relying party sends a random challenge and an RP ID. The browser calls navigator.credentials.get(), and the authenticator signs a structure containing the RP ID hash plus a clientDataJSON blob holding the challenge and the caller's origin, using a private key that never leaves the authenticator. The server verifies that signature against the public key stored at registration, then checks the RP ID hash, the challenge, and the origin.
The browser enforces which RP ID a page may request: the caller's origin must be a registrable-domain suffix of it. A page on attacker.example cannot ask for login.microsoftonline.com, and neither can login.attacker.example. That is why a perfect copy of a login page on a lookalike host cannot mint a valid assertion.
Why the FIDO2 origin check is not a phishing control by itself
Here I part company with the usual framing. The origin check is a strong control, and I do not think it is the gap. The gap is that the ceremony and the session are different protocols. The ceremony proves a user authenticated to the real origin with a key bound to that origin, at that moment. It says nothing about who holds the session cookie issued three milliseconds later.
That cookie is generally a bearer credential: whoever presents it is treated as the user. Nothing from the passkey is attached to it. So the question that matters for a WebAuthn deployment is not "did the ceremony verify" — it did — but "what is the session bound to, and for how long."
The straight relay story in headlines often breaks against an unmodified browser, because the WebAuthn API refuses to invoke for an RP ID outside the calling origin's registrable domain. That does not save the account on its own. I explain why below, and flag what I could not confirm about this campaign.
How an Adversary-in-the-Middle (AiTM) Proxy Phishing Flow Works
Proxy setup and TLS interception of the victim browser
Evilginx-class reverse proxies have been publicly documented in security research for years, so the mechanics are not secret. The victim is lured to an attacker-controlled domain that mirrors the real login page. The proxy terminates the victim's TLS session and opens its own TLS session to the real identity provider. Two connections, one process holding both in plaintext.
This keeps working because the victim is not on a spoofed hostname. They are on the attacker's real domain with a real certificate for it, which is why HSTS and certificate pinning do not fire — there is nothing wrong with either TLS session. Browsers do not pin web origins the way a mobile app pins its API. The transport is honest; the address bar is misread.
Relaying the WebAuthn ceremony in real time
The order matters:
- The real IdP generates a challenge for the real RP ID.
- The proxy forwards it into the victim's browser.
- The victim's authenticator signs.
- The signed assertion travels back through the proxy to the real server.
- The real server verifies the signature and issues a real session.
| Party | What it observes | Why it looks correct |
|---|---|---|
| Victim browser | A page on the attacker's domain, valid TLS | It connected to the domain it was given |
| Proxy | Full plaintext of both TLS sessions | It is just a client and a server |
| Authenticator | RP ID and origin of whatever requested the ceremony | It signs what the calling origin is allowed to ask for |
| Identity provider | A valid assertion for a registered credential | Signature verifies against the stored public key |
Here is the honest caveat. In a stock browser, step 2 breaks for the real RP ID, because the calling origin is attacker.example and the browser will not release an assertion for login.microsoftonline.com. My inference — and the public write-up does not describe the mechanism — is that campaigns succeeding with passkeys enabled use one of three paths: the account still permits a fallback method (password plus OTP) the proxy relays happily; the endpoint is compromised or driven remotely so the ceremony runs from the real origin; or the flow abuses credential enrollment rather than assertion. I have not confirmed which applied here, and none of these should be tested outside a system you own.
Session token capture and replay through the proxy
Whatever the entry path, the compromise lands the same way: the proxy sits on the response and keeps the session cookie, plus the refresh token in OAuth/OIDC flows. A sanitized exchange:
HTTP/2 200 OK
content-type: application/json
set-cookie: SESSION=<redacted>; Path=/; Secure; HttpOnly; SameSite=Lax
set-cookie: REFRESH=<redacted>; Path=/auth; Secure; HttpOnly
# run from a different machine and network than the browser session
curl -s -o body.json -w "%{http_code}
" -H "Cookie: SESSION=<redacted>" https://lab.example.com/api/me
cat body.jsonCaptured output from my lab tenant:
$ ./replay-session.sh
200
{"user":"[email protected]","roles":["GlobalReader"],"tenant":"lab"}
Cookie name and value are synthetic, the target is a lab tenant I control. What matters is the 200: the account is the token. No key, no device, and no origin participated in that request.
Auditing Your Own WebAuthn Login Flow: Concrete Checks
Inspect what the session cookie actually binds
Open devtools on the authenticated origin and decode the cookie. If it is JWT-shaped, this works in the console:
const raw = document.cookie.split("; ")
.find((c) => c.startsWith("SESSION="))?.split("=")[1];
const claims = JSON.parse(
atob(raw.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
);
console.table(claims);
What I look for, and what my lab token showed:
| Claim | Lab session | What its absence means |
|---|---|---|
sub, aud, exp | present | normal |
cnf / jkt (DPoP thumbprint) | absent | no proof-of-possession |
| device or key ID | absent | token is portable |
sid | present | server-side session is revocable |
| IP / ASN binding | absent | usable from anywhere |
If the cookie is an opaque encrypted blob, that is not evidence of binding — check the server-side session record instead.
Test whether the token is replayable off-device
Five minutes is enough to check this. Sign in with browser A, copy the session cookie, then from browser B on a different machine and a different network — a phone hotspot is enough — replay an authenticated API call with that cookie. Run it against your own tenant and account only.
Observed on my lab: 200 with user data, as above. A 200 means the token is bearer-shaped and replayable, and an AiTM proxy capturing it gets the same result. A 401 means something is validating context — and you should confirm what, because a WAF rule blocking unfamiliar ASNs is not a cryptographic binding.
Check conditional access and device signals
Conditional access is evaluated at sign-in. It can require a compliant device, a managed-device claim, or a low risk score before issuing a token, which genuinely raises the cost of the proxy. Where it stops is the interesting part: it does not follow the token. Once a session exists, a policy that fires only during authentication has no vote on the request that exports a mailbox.
What closes the loop is sign-in-time evaluation versus per-request evaluation. Anything enforced at the resource server — proof-of-possession, per-request device claims, sender-constrained tokens — survives capture. Anything enforced only at the front door does not.
Defenses That Actually Shrink the Window
Sender-constrained tokens and token binding
OAuth DPoP (RFC 9449) makes the client prove possession of a private key on each request with a signed proof bound to the access token, so a stolen cookie alone is inert. Token binding (RFC 8471) attempted the same idea at the TLS layer; browser support never landed broadly, and I would not build on it today. DPoP is the one I would ship.
The cost is real: a key pair per client, proof generation per call, server-side nonce and replay handling, and uneven SDK support. I would still pay it for admin consoles, mail, and anything that can exfiltrate in bulk. For a read-only marketing site, the cost is not worth it.
Device-bound credentials and short lifetimes as the pragmatic default
My ranking, in fix-first order: sender-constrained tokens where the stack supports them; device-bound passkeys (platform authenticators over synced ones) for privileged roles; aggressive session lifetimes with refresh token rotation and reuse detection; step-up authentication for high-impact actions like data export, mailbox rule changes, and new consent grants.
Rotation plus reuse detection earns its place because it does two jobs. It bounds the window, and it produces a detection signal — when the proxy's copy of a refresh token is used after the real client has already rotated past it, you get a hard failure you can alert on instead of a silent success.
Detection and monitoring as the layer after prevention
Prevention fails, so plan for the failure. The alerts I want: the same session token used from two distinct ASNs inside a short window; refresh token reuse; and post-login actions that cluster around takeover — new inbox forwarding rules, bulk downloads, new OAuth consent grants, and sign-ins from an ASN that has never appeared for that user. This is impact limitation, not prevention. It assumes the token is already stolen and asks how fast you find out.
What the Report Confirms and What I Did Not Verify
Confirmed from the public reporting: attackers used passkey phishing against Microsoft cloud accounts and exfiltrated data, per a September 2026 news write-up.
Not confirmed, and not tested by me: the specific proxy tooling used, the exact victim-side tenant configuration, whether conditional access or token protection was enabled, the entry path that got past the browser's RP ID check, and the per-victim impact. The HTTP transcript and cookie claims above are synthetic, and the replay test ran only against a lab tenant I own.
The audit checks and lifecycle recommendations are derived from how WebAuthn and OAuth/OIDC work by design, not from the campaign itself. Treat them as things to verify in your own environment.
Further Reading
- W3C Web Authentication specification — origin and RP ID binding rules
- FIDO Alliance: passkeys — passkey syncing and device binding guidance
- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP) — sender-constrained tokens
- RFC 8471: The Token Binding Protocol — the TLS-layer predecessor
- CISA: Implementing Phishing-Resistant MFA — advisory-level guidance on AiTM and phishing-resistant factors
- Microsoft: token protection in Conditional Access — binding tokens to a device key
- Microsoft: Conditional Access overview — where policy is evaluated
- The Hacker News write-up on the September 2026 passkey phishing campaign — news reporting, not a vendor advisory


