Auditing a WebAuthn Login Flow for AitM Proxy Phishing and Session Token Theft

Auditing a WebAuthn Login Flow for AitM Proxy Phishing and Session Token Theft

pr0h0
webauthnpasskeysphishingauthenticationsecurity
AI Usage (82%)

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:

  1. The real IdP generates a challenge for the real RP ID.
  2. The proxy forwards it into the victim's browser.
  3. The victim's authenticator signs.
  4. The signed assertion travels back through the proxy to the real server.
  5. The real server verifies the signature and issues a real session.
PartyWhat it observesWhy it looks correct
Victim browserA page on the attacker's domain, valid TLSIt connected to the domain it was given
ProxyFull plaintext of both TLS sessionsIt is just a client and a server
AuthenticatorRP ID and origin of whatever requested the ceremonyIt signs what the calling origin is allowed to ask for
Identity providerA valid assertion for a registered credentialSignature 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
replay-session.sh
# 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.json

Captured 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:

ClaimLab sessionWhat its absence means
sub, aud, exppresentnormal
cnf / jkt (DPoP thumbprint)absentno proof-of-possession
device or key IDabsenttoken is portable
sidpresentserver-side session is revocable
IP / ASN bindingabsentusable 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

Share this post

More posts

Comments