DEV Community

Cover image for Passkeys Can't Be Phished. Your Fallbacks Can.
Sonia Bobrik
Sonia Bobrik

Posted on

Passkeys Can't Be Phished. Your Fallbacks Can.

On July 20, 2022, at least 76 Cloudflare employees got the same text message within a single minute, each one pointing to a convincing copy of the company's Okta login page. Three of them typed in their usernames and passwords, which at most companies would be the first line of an incident report. At Cloudflare the attack went nowhere, because the second factor was a FIDO2 security key, and a key like that will not sign a login for a domain it was never registered with. If you want the plain-language case for making that kind of protection your default, this practical upgrade path to passkeys and phishing-resistant MFA walks through it step by step, from password managers to recovery codes. This post picks up where that story usually ends: after the passkey button ships, when the other doors into the same account are still standing open.

Why the Key Said No

Cloudflare's own write-up of the attack is worth reading for the mechanics alone. The lookalike domain was registered less than 40 minutes before the first text went out. Whatever a victim typed was relayed to the attacker over Telegram in real time, and the page then asked for a one-time code, so both could be replayed on the real login page before the code expired. Anyone who made it past that step was offered a download of AnyDesk, the remote access tool. Twilio, hit by a campaign with very similar fingerprints, had disclosed a breach the day before Cloudflare published.

Against SMS codes or an authenticator app, a relay like that wins. A six-digit number has no idea which website it is being typed into, and neither does a distracted person holding a phone. A WebAuthn credential does know. The browser puts the page's origin into the data the authenticator signs, and the credential itself is scoped to a single relying party, so a key registered for the real login domain has nothing to offer a lookalike. Nobody has to spot the fake URL, because the protocol checks it for them. Cloudflare said that, like Google before it, it had not seen a successful phishing attack since hardware keys became mandatory. It also made a point of not punishing the three employees who were fooled, which is the right instinct. If a system fails whenever a human has a bad day, the system is the bug.

Attackers Don't Break the Lock. They Find Another Door.

About a year later, a similar text-message lure reached Retool, and this time there was no hardware key at the end of the chain. One employee signed in on a fake identity portal. Then came a phone call from someone posing as IT, using what Retool describes as a deepfaked voice, and the employee read out one more one-time code. That code let the attacker add their own device to the employee's Okta account, which meant fresh MFA codes on demand from then on. The employee's Google Authenticator was also syncing codes to the cloud, so the hijacked Google account unlocked every other code as well. Retool's own conclusion was that its multi-factor setup had quietly collapsed into a single factor. By the time the company revoked every internal session, 27 cloud customer accounts had been taken over.

Look at which step did the real damage. It wasn't the phished password, and it wasn't really the phished code either. It was what one phishable code was allowed to do: register a brand-new authenticator owned by the attacker. That single permission turned a moment of access into persistence.

Then, in August 2025, Proofpoint published research by Yaniv Miron showing that accounts with FIDO credentials aren't automatically safe either. A dedicated Evilginx phishlet tells Microsoft Entra ID that the victim is browsing with Safari on Windows, a combination Entra ID doesn't support for FIDO sign-in. Entra ID does exactly what it was built to do: it shows an error and offers other methods. The victim picks the authenticator app, enters a code into the proxied page, and the attacker leaves with a session cookie. No cryptography was broken. The server simply believed a user agent string written by the attacker. Proofpoint said it hadn't seen the technique in the wild yet and that criminals still mostly go after accounts with weaker protection. That is the trend to plan for, because every passkey you ship makes the remaining fallbacks a more attractive target.

Your Real Security Level Is Your Weakest Path

Put those three stories side by side and a rule falls out that is easy to state and uncomfortable to apply: an account is only as phishing-resistant as the weakest path that can create a session for it or change its credentials. That covers login fallbacks, the flow that registers a new passkey or authenticator app, recovery by email or SMS, magic links, long-lived refresh tokens and whatever your support team can do from an admin panel. Most passkey rollouts add one strong path and leave every old one in place. That raises the average while leaving the minimum exactly where it was, and attackers only ever shop at the minimum.

Designing the Doors Around the Passkey

You can't delete every fallback, because people lose phones and drop laptops in lakes. What you can do is make each remaining door as hard to open as the account deserves:

  • Let the account decide, not the client. If an account has a passkey, a sign-in that reports "no WebAuthn here" should be steered to another phishing-resistant option, such as the cross-device flow where the user scans a QR code with their phone, or to a slower recovery path. It should not get a menu of codes to type. In a proxied session the attacker writes every header, the user agent included.
  • Gate enrollment like a wire transfer. Registering a new passkey, adding an authenticator app or changing the phone number should require the strongest factor already on the account. Retool's attacker turned one spoken code into lasting access because enrollment accepted a weak factor.
  • Treat a downgrade as an event. When an account that has used a passkey for months suddenly signs in with an SMS code from a new device, notify every enrolled device, require step-up before sensitive actions and consider a limited session until the user confirms from a trusted device.
  • Put time on recovery. A recovery flow that finishes in seconds is just a login flow with fewer checks. A waiting period, alerts to every channel on file and a one-tap "this wasn't me" give the real owner a chance to cancel.
  • Ask for a second passkey early. Most recovery requests happen because someone lost their only credential. A second passkey, ideally a hardware key or one stored in a different ecosystem, keeps people away from the weak door in the first place.
  • Verify the origin against a fixed allowlist. A page on any of your subdomains can request an assertion scoped to the parent domain, so the origin inside clientDataJSON is what separates your login page from, say, a user-uploads subdomain. Libraries compare it only with the values you pass in, so hardcode them rather than reading them from request headers.

Make the Strong Door the Easy One

None of this holds if the passkey path is a chore, because people route around friction and the fallback quietly becomes the main entrance. The encouraging part is that passkeys are now faster than what they replace. In Microsoft's World Passkey Day update, the company reported that passkey sign-ins succeed about 98% of the time compared with 32% for passwords, and finish eight times faster than a password plus MFA. Microsoft also stopped presenting every sign-in method up front. Its sign-in screen now leads with the best method an account already has, a change that cut password use by more than 20% in its experiments. That is the list above applied to UX: defaults decide which door people walk through.

On the web, the cheapest way to make passkeys the default is conditional mediation, which puts saved passkeys into the browser's autofill right next to saved passwords:

// Pair with: <input name="username" autocomplete="username webauthn">
async function offerPasskeyAutofill(challenge) {
  if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return;
  if (!(await PublicKeyCredential.isConditionalMediationAvailable())) return;

  const assertion = await navigator.credentials.get({
    mediation: "conditional",
    publicKey: { challenge, rpId: "example.com", userVerification: "preferred" },
  });

  // Send the assertion to your server, which checks the challenge, origin and RP ID.
}
Enter fullscreen mode Exit fullscreen mode

Call it when the page loads so the request is already waiting. Users who have a passkey see it the moment they focus the username field, and nobody gets pushed through a separate ceremony they don't understand.

Audit the Doors Before Someone Else Does

Here is a one-hour exercise for your next sprint. List every way a session can come into existence for one of your users and every way their credentials can change. Next to each entry, write down how it authenticates, then circle the weakest one. That line, not the passkey button on your login page, is your actual security level, and it is the line the next phishing kit will be written for. Cloudflare's three employees were fooled and the attack still failed, because the key was the only second factor its systems would accept. Build accounts where that is true, and a bad day for one of your users stays a bad day instead of turning into your breach notice.

If you've already shipped passkeys, how did you handle fallback and recovery? Tell me in the comments what worked and what you had to roll back.

Top comments (0)