What you'll learn
- Quick answer
- The core idea: a fresh key pair, per site, that never leaves your device
- The registration ceremony
- The authentication ceremony, and the origin binding that makes it phishing-resistant
- Demonstrating the core mechanism with a real key pair
- Attestation vs. assertion
- Platform vs. roaming authenticators, and what a passkey actually is
- FAQ
Quick Answer
WebAuthn has your device generate a brand-new public/private key pair for every site you register with, keep the private key locked inside an authenticator that never exports it, and prove your identity by signing a server-issued random challenge with that private key. A passkey is simply a WebAuthn credential that's been made discoverable and synced across your devices, so you don't need the original device to log in. It's phishing-resistant in a way OTP codes fundamentally are not, because the browser cryptographically binds every signature to the real site's origin - we verified this against the W3C spec and demonstrated the core mechanism with a real generated key pair and a real rejected phishing replay.
The core idea: a fresh key pair, per site, that never leaves your device
Passwords and OTP codes are both shared secrets — something you know, or something you're told, that you then hand over to prove who you are. The problem with shared secrets is right there in the name: if you can type it in, you can be tricked into typing it into the wrong place, and anyone who captures it in transit can replay it.
WebAuthn is built on public-key cryptography instead. When you register with a site, your device's authenticator — a fingerprint sensor, Face ID, a hardware security key, whatever handles authentication on your device — generates a brand-new key pair specifically for that one site. The private key is generated and stored inside the authenticator and is designed to never leave it, not even to the browser or operating system. Only the public key ever gets sent to the site's server. From that point on, "logging in" means the server sends a random challenge, your authenticator signs it with the private key after you unlock it (fingerprint, PIN, face), and the server checks that signature against the public key it already has on file. Nothing secret ever crosses the network, because there's nothing secret to send — a signature proves possession of the private key without revealing it.
The registration ceremony
WebAuthn's spec calls these flows "ceremonies" precisely because they're a fixed sequence of steps involving more than one party. Verified against the W3C spec and standard WebAuthn explainers, registration goes:
- The relying party (the site's server) generates a random challenge and sends it to the browser, along with information identifying the relying party and the user account being registered.
- The browser forwards this to the platform's WebAuthn API, which hands it to the authenticator.
- The authenticator requires a user gesture to proceed — a fingerprint scan, face match, or PIN entry — confirming a real person is physically present and consenting, not just malware calling an API.
- The authenticator generates a new key pair for this specific site and signs a response that includes the new public key and an attestation statement.
- The browser returns this response to the server, which verifies it and stores the public key against the user's account.
From here on, that specific key pair is permanently tied to that one site. The same authenticator generates a completely different key pair if you register with a different site — there's no shared secret, and no single credential an attacker could compromise once and reuse everywhere.
The authentication ceremony, and the origin binding that makes it phishing-resistant
Logging back in is the same shape with a smaller payload: the server issues a fresh random challenge (so an old captured response can never be replayed - it was only ever valid for a challenge that's now used up), and your authenticator signs it, again only after you unlock it.
The part that actually makes this phishing-resistant, verified against current explainers of the mechanism: the browser itself — not your code, not the website's JavaScript — automatically includes the current page's real origin as part of the data that gets signed, bound to the specific relying party ID the credential was registered for. If you're on a convincing lookalike domain, the browser includes that domain's real origin in the signed data, not the domain the phishing page is pretending to be. The authenticator then can't even find a matching credential for that origin, because the credential it holds was registered against the real site's origin, not the lookalike's. There's no secret for the attacker to steal here even in the worst case — the authentication simply fails silently, because the cryptographic binding never matches.
This is the fundamental difference from an OTP code or a password: those are just strings of characters, and a person (or a script) can be fooled into typing a correct string into the wrong box. A WebAuthn signature is not a string you can be tricked into copying somewhere else — it's bound, by the browser, to the origin it was actually created for.
Demonstrating the core mechanism with a real key pair
The full WebAuthn ceremony involves browser APIs that aren't practical to simulate in a plain script, but the cryptographic heart of it — sign a challenge bound to an origin, verify with the stored public key, and watch a forged origin fail — is straightforward to demonstrate for real using Python's cryptography library.
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.exceptions import InvalidSignature
import os
# Registration: authenticator generates a fresh keypair for this site only
private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key()
# Authentication: server issues a random challenge, browser binds the real origin
challenge = os.urandom(32)
rp_id = "priodemy.com"
signed_data = rp_id.encode() + challenge
signature = private_key.sign(signed_data, ec.ECDSA(hashes.SHA256()))
# Server verifies with the public key it stored at registration time
public_key.verify(signature, signed_data, ec.ECDSA(hashes.SHA256()))Running this for real produced:
Server-issued random challenge (hex): b5074ab1fd7e809aacaa4b2dc3695aeaba8cc78d50020f4eed618b74dee0cfee
Relying party ID bound into the signed data: 'priodemy.com'
Signature produced by the authenticator (hex, truncated): 3044022074c2c8080380be96f4e5f488b3549704...
Server verification with the stored public key: VALID SIGNATURE (login accepted)Then, simulating an attacker who captured that exact signature and challenge but is hosting a lookalike domain, we attempted to verify the same signature against data built with the fake origin instead of the real one:
Verification against phishing-site data: INVALID SIGNATURE (replay correctly rejected)That single line is the entire phishing-resistance property, proven mechanically rather than asserted: the signature is cryptographically tied to the exact bytes it was created over, which include the origin. Change the origin, and the exact same signature that was valid a moment ago now fails verification, every single time, with no exceptions.
Attestation vs. assertion
These two terms get used precisely in the spec, and verified against the W3C spec and MDN's own explainer, the distinction is which ceremony produced the response. Registration produces an attestation response — it includes the newly generated public key plus, optionally, a statement from the authenticator's manufacturer vouching for what kind of device generated it (useful for an organization that wants to only accept registrations from specific hardware models; most consumer sites set attestation to "none" and don't bother checking it). Authentication produces an assertion response — no new key, no attestation data, just a signature, the user handle, and authenticator data proving the existing registered key signed this specific challenge.
In short: attestation happens once, at registration, and is about proving what generated the key. Assertion happens every time you log in, and is about proving you still hold that key.
Platform vs. roaming authenticators, and what a passkey actually is
WebAuthn doesn't mandate any particular kind of authenticator, and verified terminology splits them into two categories. A platform authenticator is built into the device you're using — Windows Hello, Touch ID, Face ID, Android's biometric unlock. A roaming (cross-platform) authenticator is a separate piece of hardware you connect over USB, NFC, or Bluetooth — a YubiKey or similar FIDO2 security key — useful specifically because it isn't tied to one device.
A passkey isn't a separate protocol from WebAuthn — it's a WebAuthn credential with two specific properties layered on: it's a discoverable credential (also called a resident key), meaning the authenticator itself can list which accounts it has credentials for without the site having to remind it first, which is what lets you log in by just picking your account instead of typing a username; and it's typically synced across your devices via your platform's account (Apple's iCloud Keychain, Google's Password Manager, and similar), so losing one device doesn't mean losing the credential. The underlying challenge-response cryptography is identical to any other WebAuthn credential — "passkey" is really a marketing and UX label for "a synced, discoverable WebAuthn credential," not a new mechanism underneath.
