← Back to blog

Why are passkeys phishing resistant?

· Brandon Corbett · 11 views
passkeysphishingauthenticationseamless

I build a passwordless authentication product. If you had stopped me on the street last month and asked why passkeys are phishing resistant, I would have said "the private key never leaves your device" and walked away feeling smart.

That sentence is true. It is also not the answer. A password manager keeps your password on your device too, and you can still paste it into a fake login page. Something else is going on, and I realized I had been repeating the conclusion without being able to walk through the mechanism. So I sat down with the WebAuthn Level 3 spec (a W3C Recommendation as of August 2026) and a lot of coffee.

This is what I found, written for the version of me from a month ago.

How phishing actually works

Phishing almost never breaks cryptography. It gets you to hand over something that works, on a site that is not the one you think it is.

A password is the obvious case. But the second factors most of us grew up with fail the same way:

  • SMS and authenticator app codes. You read six digits and type them in. If you type them into a fake page, the attacker types them into the real one a second later. Tools that sit in the middle and relay everything in real time make this routine.
  • Push approvals. The attacker signs in as you, your phone buzzes, and you tap approve because you were just signing in. Or because it buzzed 40 times and you wanted it to stop.

In every one of these, the only thing checking whether you are on the right site is you. You look at the URL, you look at the logo, you decide. NIST says this plainly: in SP 800-63B-4, section 3.2.5, an authenticator that relies on you manually entering its output is not phishing resistant, and neither are OTPs or out-of-band methods like push.

Humans are bad at reading URLs. I am a human. I have been fooled by a lookalike domain, and I do this for a living.

A real one

In July 2022, someone texted at least 76 Cloudflare employees a link to cloudflare-okta.com, a domain registered less than an hour earlier. Three people entered their username and password. Cloudflare wrote it up, and the attack went nowhere, because every employee signed in with a FIDO2 security key.

Three smart people at a security company fell for the page. Their keys did not.

The trick: the browser writes down where you are

Here is the sign-in ceremony with the parts that matter for phishing pulled out.

  1. The server makes a random challenge and sends it to the page.
  2. The page calls navigator.credentials.get().
  3. The browser builds a small JSON object called the client data. It contains the challenge and the origin of the page that made the call. The page does not get to fill that field in. The spec defines it as the origin of the requester "as provided to the authenticator by the client", and the client is the browser.
  4. Your authenticator (phone, laptop, security key) signs two things together: its own authenticator data, which includes a SHA-256 hash of the site's RP ID (usually its domain), and a SHA-256 hash of that client data.
  5. The server checks the signature with the public key it stored at registration, then checks that the origin, the challenge and the RP ID hash are the ones it expects.

The client data on a real sign-in looks something like this:

{
  "type": "webauthn.get",
  "challenge": "k3Jd9...",
  "origin": "https://app.example.com",
  "crossOrigin": false
}

If the same ceremony ran on a phishing page, the browser would write "origin": "https://app-example.login-verify.com", and nothing the attacker's JavaScript does can change that. The signature covers it. Change one byte and the signature fails.

So the thing I had been missing: a passkey does not prove "I am Brandon." It proves "I am Brandon, on this site, answering this challenge." The site is part of what gets signed. NIST calls this "verifier name binding", and it is the property that makes the difference.

Two locks

What surprised me is that phishing gets stopped twice, at two different points.

The first lock is in the browser, before anything is signed. A passkey is created for one RP ID, and the browser will only use it on a page whose domain matches that RP ID (example.com or a subdomain of it). On the phishing domain, the browser looks for a passkey for that domain, finds none, and there is nothing for you to approve. You cannot hand over what you were never offered. This is the part that saved those three Cloudflare employees. They were willing. The key had nothing to say to that site.

The second lock is on the server. Say an attacker gets a real signed response some other way. It still carries their origin and its RP ID hash, or a challenge the server already used. The server refuses it. In Seamless Auth this is the expectedOrigin and expectedRPID arguments on every registration and sign-in verification, and challenges live server side for 300 seconds and are deleted the moment they are read, so a captured response cannot be replayed.

The first lock depends on the browser. The second depends on the server actually doing its checks. Keep that in mind for later.

"Scan this QR code" is not a loophole either

My next question was the cross-device flow, where your laptop shows a QR code and you approve on your phone. Couldn't an attacker show you their QR code over email or a video call and have you sign them in?

That flow (the spec calls it the hybrid transport) uses the QR code to set up the connection, but it also requires a Bluetooth proximity check between the phone and the computer asking for the sign-in. Your phone has to be near the machine. An attacker on the other side of the country does not have a computer next to your phone. The spec's wording for hybrid is a combination of a data transport "and proximity mechanisms", which is a fancy way of saying exactly that.

Where you can still throw it away

Almost every way passkeys fail is something the site did. This is the section I most want developers to read.

Fallbacks. You are only as phishing resistant as your weakest sign-in method. If a user has a passkey but your login page will also take a password or an emailed code, the phishing page simply never asks for the passkey. It asks for the code. Same with recovery: if "lost my passkey" sends a link to your inbox, your passkey is exactly as strong as your email account.

Out of the box, Seamless Auth lets users who have a passkey fall back to other methods (passkey_login_fallback_enabled is on), and phishing_resistant_only is off. That is on purpose: turning on phishing resistant only mode before everyone has a passkey locks people out, and locking people out is its own kind of security failure. The passwordless without lockout guide walks through getting there in steps: check coverage, turn off fallback, remove the email based methods, then turn on the strict mode. In strict mode, email based recovery is gone and only recovery codes and device replacement are left.

What happens after sign-in. Passkeys protect the sign-in. They do nothing for the session cookie you hand out afterwards. Malware on the user's machine that steals cookies skips the whole ceremony. Short sessions, binding sessions to the device, and step-up checks for sensitive actions are separate work.

Your own origin. The browser trusts any page on your RP ID's domain. Cross-site scripting on your login page runs as your origin and can start a ceremony that will pass every check. A forgotten subdomain that someone takes over can do the same if your RP ID covers it. Your passkeys are only as trustworthy as everything that can serve pages under that domain.

A server that does not check. All of the server side protection is steps in a list that someone has to implement. A relying party that skips the origin check, or allows every origin to make local development easier, has quietly turned off the second lock. This is one reason we run the FIDO conformance tools: a good chunk of those tests send a mismatched origin or challenge and check that the server says no.

A compromised device. If the attacker controls your browser, they control what it writes in the origin field. At that point you have bigger problems than phishing.

What I took away

Phishing works because we ask people to check something people cannot reliably check. Passkeys take that job away from the person and give it to the browser, which reads the URL correctly every time, even at 11pm when you are rushing. Then the server checks the browser's work.

It took me years of building in this space to say that in one breath, and I am a little embarrassed about it. I would rather be embarrassed and actually know it, though.

If you are an auth person who has been nodding along going "yes, obviously", I would love to hear what I got wrong or left out. And if you are not, what is the thing in your field you could talk about for an hour but could not explain from first principles if someone put you on the spot?

© 2026 Seamless Auth