
Passkeys and WebAuthn Level 3: How Passwordless Sign-In Actually Works
WebAuthn Level 3 became a W3C Recommendation in August 2026, five years after Level 2, and passkeys now sit behind billions of active credentials. This guide walks through the cryptography that makes a passkey unphishable, the new Level 3 features, and a staged rollout plan for an app that still has password users.
Five Billion Passkeys and Almost Nobody Signed In With One
In May 2026 the FIDO Alliance put a number on World Passkey Day: roughly five billion passkeys in active use worldwide. Awareness sat at 90%, three quarters of respondents had enabled at least one, and 49% said they use a passkey whenever a site offers the option. Google has counted more than 800 million accounts with a passkey and 2.5 billion sign-ins completed with one.
Here is the number that matters more. When researchers manually verified passkey support across the top 100,000 domains, they confirmed 386 sites. That is about four in a thousand. FIDO's own enterprise survey of 1,400 decision-makers found 57% of organisations still treat passwords as the primary workforce sign-in method.
Both facts are true at once, and the distance between them is the entire problem. A passkey sitting next to a password field has changed nothing. What separates a real rollout from a checkbox is engineering work, and that work got meaningfully easier this summer when the W3C finally published WebAuthn Level 3.
What WebAuthn Level 3 Actually Changed
On 25 August 2026 the W3C published Web Authentication: An API for accessing Public Key Credentials Level 3 as a Recommendation, under the identifier REC-webauthn-3-20260825. It supersedes Level 2, which dates from 2021. The Candidate Recommendation Snapshot landed on 26 May 2026 and the implementation report is dated 26 June 2026, drawn from the web-platform-tests suite.
Level 2 is the specification that turned phishing-resistant authentication from a research topic into something you can ship. Level 3 is the release that made it tolerable to use, mostly by codifying the passkey behaviour browsers had already been shipping since 2024.
| Area | What Level 3 specifies |
|---|---|
getClientCapabilities() | One call returning nine capability flags, so a client can branch on features instead of sniffing user agents |
| Related origin requests | One credential across a bounded set of related domains |
| Cross-origin creation | navigator.credentials.create() inside a cross-origin iframe, gated by Permissions Policy |
topOrigin | A new member of the client data carrying the full top-level origin of the requester |
| Conditional mediation for creation | Passkey-style account creation prompts, not just sign-in |
| Signal methods | Three methods letting a relying party report credential state up and down the stack |
| JSON serialisation | Base64url helpers so binary blobs stop leaking into application code |
| Backup flags | BE and BS let a server tell whether a credential was backed up or moved to a new device |
| Compound attestation | More than one attestation statement in a single create call |
prf extension | A standard route to key material derived from the credential, replacing a decade of vendor-specific hacks |
None of this changes the cryptography. It changes how much of the protocol you have to hand-roll.
Browser support was never the risk. As of 24 August 2026, caniuse put full passkey support at 93.07% of global page views with zero partial implementations, and WebAuthn itself at 92.41% full plus 3.25% partial. The gap between those two figures is the interesting part: the browser could already do this in 2021, but only for physical security keys. Syncable credentials are what moved the number.
Why a Passkey Cannot Be Phished
A password is a shared secret. Both you and the server hold the same string, so any software that handles the string can leak it. A passkey is a key pair. The private half stays inside the authenticator, typically behind a Secure Enclave or TPM, and the server only ever receives a signature.
Signing alone would not stop phishing, because an attacker could build a fake login page and simply forward the signature to the real server. Three checks combine to close that hole.
The Relying Party ID. A credential is minted for a specific RP ID, which is a domain suffix. A passkey created for axonixtools.com is scoped to that suffix and cannot be requested by axonixtools.com.evil.example.
The origin. The browser writes the requesting origin into clientDataJSON, and the authenticator independently decides whether the calling context matches its own stored RP ID. A look-alike domain is not the Relying Party, so the authenticator never offers the credential in the first place.
The challenge. The server issues a fresh random challenge with every request and rejects any assertion whose embedded challenge does not match. That makes a captured signature worthless on replay.
BROWSER AUTHENTICATOR (Secure Enclave) SERVER
| | |
1. | ---- challenge, rpID ----> | |
| | (server stored the challenge)|
2. | | |
| <--- attestation + public key --------------------------- |
| | |
3. | ---- assertion + signature + clientDataJSON -----------> |
| | verify: rpID, origin, |
| | challenge, UV, counter|
4. | | |
| | <----- session cookie ------- |
| | |
| Private key NEVER leaves the authenticator. |
| A phishing page cannot supply the right origin, so the |
| authenticator refuses before any signature is produced. |
That last line is the whole argument for passkeys. The refusal happens before the private key is touched, so there is nothing for an attacker to intercept.
Registration: The Full Round Trip
Registration creates the credential and stores four things server-side: the credential ID, the public key, the signature counter, and the user's UV (user verified) preference. A widely used Node library is SimpleWebAuthn.
// POST /api/auth/register/options (after the user is already signed in)
import { generateRegistrationOptions } from "@simplewebauthn/server";
const user = await currentUser();
const options = await generateRegistrationOptions({
rpName: "Axonix",
rpID: "axonixtools.com", // must match the production domain exactly
userName: user.email,
userDisplayName: user.name,
userID: crypto.randomUUID(), // opaque, not the email
attestationType: "none", // privacy: send no device fingerprint
excludeCredentials: await listCredentialIds(user.id),
authenticatorSelection: {
residentKey: "preferred", // required for discoverable passkeys
userVerification: "preferred",
},
});
return Response.json(options);
// POST /api/auth/register/verify
import { verifyRegistrationResponse } from "@simplewebauthn/server";
const verification = await verifyRegistrationResponse({
response: attestation, // straight from the browser
expectedChallenge: loadChallenge(user.id), // your own stored copy
expectedOrigin: "https://axonixtools.com", // hard-coded, never from a header
expectedRPID: "axonixtools.com",
requireUserVerification: false,
});
await db.credentials.insert({
userId: user.id,
credentialId: Buffer.from(verification.credentialID).toString("base64url"),
publicKey: Buffer.from(verification.credential.publicKey).toString("base64url"),
counter: verification.authenticationInfo.newCounter,
transports: attestation.response.transports ?? [],
backedUp: verification.registrationInfo.credentialBackupState, // BE flag, Level 3
});
return Response.json({ ok: true });
Three details cause most production incidents here.
Hard-coding expectedOrigin and expectedRPID matters because deriving them from request headers lets an attacker who reaches your server through a proxy or a forwarded host supply the matching origin. Set them from configuration.
Setting rpID to localhost during development and shipping that value is a silent outage later, because the RP ID is baked into the credential. Every credential registered under localhost stops resolving the moment production uses a real domain, and there is no way to rebind it.
Omitting excludeCredentials lets a returning user enrol a second passkey for the same authenticator, filling their account with duplicates that all work and none of which they can tell apart.
Sign-In: The Full Round Trip
Authentication is the mirror image. The assertion comes back, the library verifies it against the stored public key, and the counter tells you whether the credential was cloned.
// POST /api/auth/login/options
import { generateAuthenticationOptions } from "@simplewebauthn/server";
const options = await generateAuthenticationOptions({
rpID: "axonixtools.com",
allowCredentials: user ? await listCredentialIds(user.id) : [],
userVerification: "preferred",
});
await storeChallenge(options.challenge, { userId: user?.id ?? null });
return Response.json(options);
// POST /api/auth/login/verify
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
const stored = await db.credentials.findById(assertion.id);
if (!stored) return Response.json({ error: "unknown credential" }, { status: 400 });
const verification = await verifyAuthenticationResponse({
response: assertion,
expectedChallenge: await takeChallenge(), // single use
expectedOrigin: "https://axonixtools.com",
expectedRPID: "axonixtools.com",
credential: {
id: stored.credentialId,
publicKey: stored.publicKey,
counter: stored.counter,
transports: stored.transports,
},
requireUserVerification: false,
});
// A counter that goes backwards means the authenticator was copied.
if (verification.authenticationInfo.newCounter <= stored.counter) {
await flagPossibleClone(stored);
}
await db.credentials.update(stored.id, {
counter: verification.authenticationInfo.newCounter,
});
await issueSessionCookie(user.id);
The counter check is the only part of WebAuthn that detects credential theft, and many implementations skip it. Passkeys sync across a user's own devices, which means a legitimate assertion from a second device can arrive with a lower counter than expected depending on your sync model. Decide deliberately how you treat that, and log it, rather than discovering the policy during an incident.
Device-Bound or Synced: The Assurance Trade-Off
These two passkey types look identical to a user and mean very different things to a compliance auditor.
| Device-bound | Synced | |
|---|---|---|
| Lives in | Secure Enclave or TPM | iCloud Keychain, Google Password Manager, Microsoft Password Manager, a vault |
| Survives device loss | No, you re-enrol | Yes, it reappears after account sign-in |
| Reaches a new platform | Only by re-enrolling | Automatically |
| NIST SP 800-63B-4 | Permitted at AAL2 and AAL3 | Permitted at AAL2, barred at AAL3 |
| Reasonable use | Admin console, root, signing, production database access | Everyday SaaS, email, dashboards |
| Risk it addresses | Stolen laptop | Lost phone, new phone, new laptop |
NIST SP 800-63B-4, finalised in 2025, is the document that unblocked enterprise adoption by formally recognising syncable authenticators as AAL2. The same document barred them at AAL3, which is why privileged access still needs a device-bound credential or a hardware key.
Enterprise reality in 2026 is a mix rather than a winner. Among organisations using passkeys, 50% run a combination of device-bound and synced, 22% lean primarily to synced, and 23% primarily to device-bound.
One thing to be clear about for your users: synced passkeys cannot yet move between competing password managers through a standard protocol. FIDO's Credential Exchange Format reached Proposed Standard status with errata in March 2026, but the Credential Exchange Protocol that would perform the transfer is still a Working Draft. Until that lands, the practical advice is to enrol important accounts on more than one device rather than assuming a credential can be moved.
The Part That Makes It Feel Like Autofill
Registration and authentication are the plumbing. The feature that moved conversion numbers is conditional UI, where passkeys appear inside the normal autofill dropdown with no separate button.
<form>
<!-- The "webauthn" token is what lets the browser offer passkeys here -->
<input type="email" name="email" autocomplete="username webauthn" />
<input type="password" name="password" autocomplete="current-password" />
</form>
// Fire this once on page load, then let the browser drive it.
async function warmUpPasskeys() {
if (!window.PublicKeyCredential) return;
// Level 3: ask what this client can actually do instead of guessing.
const caps = await PublicKeyCredential.getClientCapabilities?.();
if (caps && !caps.conditionalGet) return;
try {
await navigator.credentials.get({ publicKey: optionsJSON, mediation: "conditional" });
// Resolves with a credential, or rejects with NotAllowedError if dismissed.
// The browser renders the passkey picker itself. Do not draw your own UI.
} catch (err) {
if ((err as Error).name !== "NotAllowedError") console.warn(err);
}
}
Password users are untouched, which is what makes this safe to ship first. If the browser cannot offer a passkey, the form behaves exactly as it did before.
Level 3 added a prf extension that finishes a hack developers have been shipping since 2019. You can now derive an encryption key from the credential itself: the first authentication triggers the hmac-secret extension and returns 32 bytes, which you import as an AES-GCM key and use to encrypt notes or tokens that travel with the session.
const prf = credential.getClientExtensionResults().prf;
if (prf?.results?.first) {
const keyBytes = new Uint8Array(prf.results.first);
const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]);
// The raw key exists only in memory. Persist the derived ciphertext.
}
Treat those bytes as the master secret they are. The pattern is to derive with HKDF, wrap with the session key, and discard the plaintext.
Shipping Passkeys Without Breaking Sign-In
A rollout that removes the password field on day one fails and takes your support queue with it. This sequence keeps every existing user working throughout.
- Add passkey registration inside account security settings, behind authentication. Existing password users enrol a first passkey while still able to sign in the old way. One user can hold several passkeys across phone, laptop and desktop.
- Add conditional UI to the login form. Password users see no change; passkey users get the autofill row.
- Offer passkeys at sign-up as the default path, with an explicit opt-out that falls back to password.
- Prompt existing password users after a successful sign-in, and track the rate. Real-world conversions land somewhere between 20% and 40% when the prompt is a suggestion rather than a demand.
- Add a passkey-only mode for accounts that want it, and keep one recovery path that does not depend on a device.
Microsoft made the aggressive version of this bet public: Entra ID moves passkeys to the default authentication experience for users enabled for SMS or voice in public cloud tenants, with SMS and voice delivery retired for those tenants in February 2027. It is a useful forcing function and a risky model for a product with consumer sign-up.
Recovery Is Where Rollouts Die
Every passkey conversation eventually arrives at the user who has a new phone, no password manager, and no second device. If your only recovery path is "the same factor you just lost", adoption stalls permanently and you will not see it in your metrics, because the affected users simply stop returning.
You need at least one path that does not depend on a passkey: an emailed magic link, an SMS code on a verified number, a set of single-use recovery codes generated at enrolment, or a support verification flow. Recovery codes are worth the extra code: generate ten at enrolment, show them once, and hash them server-side.
Plan for the case where the account loses every device. The FIDO survey data is a warning here. 49% of consumers use a passkey whenever one is offered, which means half of them will eventually hit a path where a passkey is not available and the fallback becomes the only route in. Design that route on purpose rather than discovering which endpoint it is.
Metrics That Tell You the Truth
Counting registered passkeys tells you almost nothing. Track four numbers instead.
Enrolment rate, the share of active users holding at least one passkey. This is a vanity metric on its own, and it is the number most teams report.
Authentication share, passkey sign-ins divided by all successful sign-ins. FIDO's 2025 Passkey Index, built from deployment data at Amazon, Google, Microsoft, PayPal, Target and TikTok, put this at 26% while 93% of accounts were eligible and 36% had enrolled. Creation is not adoption.
Success rate per attempt. FIDO's same dataset reports 93% for passkeys against 63% for other methods, and average sign-in time of 8.5 seconds against 31.2. Google measured a 30% higher success rate and 20% faster logins across 2.5 billion passkey sign-ins.
Fallback rate, the share of passkey attempts that fail and drop to a password. This is the number that catches a broken expectedOrigin before your support tickets do.
Support volume is the fifth metric, and it is the one executives notice. Deployments report 60% to 80% fewer password reset tickets and around 81% fewer sign-in related help desk calls, because a cryptographic key cannot be forgotten.
What Passkeys Do Not Fix
Passkeys replace the credential. They leave the rest of the attack surface where it is.
Session hijacking is untouched. A stolen cookie still works until it expires, so your session cookies need HttpOnly, Secure and SameSite, and you still want short lifetimes with rotation.
Malware with screen and keyboard control is untouched, because a keylogger can approve a prompt. Device-bound passkeys raise the bar for bulk theft, and that is genuinely useful, but a fully compromised endpoint is still a compromised endpoint.
Account recovery is untouched, and moving it to a single factor recreates the problem you were trying to remove.
Shared and delegated accounts resist passkeys entirely, because a passkey is bound to one human presence. Service accounts, CI runners and legacy integrations need machine identity, which is a different standard.
Where to Try the Cryptography Yourself
You do not need a real authenticator to see how the pieces line up. These tools let you inspect the same structures WebAuthn relies on, all running locally in your browser.
Generate a high-entropy credential to compare against what a passkey removes from the threat model, with our Password Generator. Hash it with SHA-256 or Argon2-style derivations using the Hash Generator to see exactly why a leaked database of hashes without user-specific salt is still a liability. Decode and inspect a bearer token with the JWT Decoder, because the most common post-login compromise is a token in local storage. Generate nonces and challenge values with the UUID Generator, and move the binary payloads that WebAuthn returns through the Base64 Encoder to see the shape a real assertion takes.
Questions People Actually Ask
Can someone phish a passkey? A look-alike page cannot. The credential is scoped to a domain suffix at creation, and the authenticator checks the calling origin before producing any signature, so a fake page is refused rather than tricked. What remains possible is everything after sign-in, including a stolen session cookie or malware that approves the real prompt.
What happens if I lose every device? You need a recovery path that does not depend on a passkey, and you should define it before you offer enrolment. Recovery codes generated at enrolment are the most reliable option, with an emailed magic link as a fallback. A passkey-only account with no recovery route is an account you cannot recover.
Are synced passkeys actually secure, or just a password manager with extra steps? They satisfy NIST SP 800-63B-4 at AAL2, which is the same assurance level as a hardware token for most commercial and enterprise requirements, and they are barred at AAL3. A synced passkey cannot be phished, and losing your phone no longer locks you out. The caveat is that the credential is exportable from the cloud account, which is exactly why it is not permitted for privileged access.
Do I need to drop passwords entirely? No, and most successful deployments have not. Passwords stay as a fallback while passkey enrolment climbs, then get disabled per account rather than globally. The FIDO data supports this split: 26% of sign-ins at member companies already used a passkey while 57% of organisations still called passwords primary.
What breaks if I get expectedOrigin wrong?
Every authentication fails, and the error is opaque because the library reports a signature or origin mismatch rather than a missing credential. Deriving the expected origin from request headers instead of configuration is the usual cause, and it is also a security hole on its own. Hard-code both values and read them from environment config at startup.
Is WebAuthn Level 3 required to offer passkeys?
No. Browsers shipped passkey behaviour years before the specification caught up, and Level 3 mostly codifies and cleans up. It matters for related origins, getClientCapabilities(), topOrigin validation, the backup flags and the prf extension, all of which you can skip on your first implementation.
Can I move a passkey from iPhone to Android? Not through a standard mechanism yet. The Credential Exchange Format is a Proposed Standard, but the protocol that moves credentials between providers is still a draft that FIDO explicitly says is not a basis for implementations. Enrol on more than one device for now.

Muhammad Haroon
Content StrategistA passionate Senior Software Engineer with extensive experience in building enterprise-grade applications using modern technologies. His expertise spans across...
Share this article
Discover More
View all articles
Inside AI Coding Agent Harnesses: OpenCode & Claude Code
A foundation model alone cannot edit files, run test suites, or debug code. Explore how modern coding agent harnesses like OpenCode, Claude Code, and DeepSeek scaffold raw LLMs with tool dispatch loops, subagent pipelines, and context compaction engines.

LLM Tokens & Prompt Caching: The Practical Guide
A hands-on engineering guide to LLM tokens. We break down Byte-Pair Encoding quirks, hidden whitespace costs, prompt caching mechanics, and the exact context architecture that reduced our production AI bill by 68%.

Cursor vs Copilot vs Claude Code: Which AI Coding Assistant Wins in 2026?
I spent 30 days building the same project with Cursor, GitHub Copilot, and Claude Code. Here's my brutally honest comparison, including which one I actually kept using, where each one fails, and the exact scenarios where one beats the others by a mile.
Use These Related Tools
View all toolsRandom Password Generator
Generate strong, secure passwords with custom rules.
IP Address Lookup
Find your public IPv4 address and detailed geolocation/ISP information instantly.
JWT Debugger
Decode, verify and debug JSON Web Tokens. Client-side only for maximum security.
htaccess Generator
Generate Apache .htaccess rules for redirects, HTTPS enforcement, and security headers.
Need a tool for this workflow?
Axonix provides 100+ browser-based tools for practical development, design, file, and productivity tasks.
Explore Our Tools