Sign in

Libre University uses your GitHub account. Signing in is only needed to sit a final test, so the score is kept on your profile.

Passkeys and WebAuthn

Every credential so far is a secret the user hands over, which means a convincing page can ask for it and receive it.

The previous lesson ended on exactly that failure: a TOTP code typed into the wrong site works at the right one. This lesson is about the credential that closes it. If you have arrived here directly, what you need is the idea of a public and private key pair, where the private key signs and the public key verifies, and that a browser origin is the triple of scheme, host and port.

What changes when the secret never leaves the device

A passkey is a key pair. When a user registers, their device generates a fresh pair, keeps the private key, and gives the site the public key. Signing in means the server sends a random challenge, the device signs it, and the server verifies the signature against the public key it stored.

Three consequences fall out immediately, and they are worth stating before any of the mechanics.

Nothing worth stealing is stored on the server. The database holds public keys. An attacker who takes the whole users table gets nothing they can authenticate with, and there is no hashing decision to make, no work factor, no offline cracking to slow down. The entire second lesson becomes unnecessary for accounts that use only passkeys.

Nothing is replayed. The signature covers a fresh challenge, so a captured signature is worthless the moment the challenge is spent. There is no window as there is with TOTP.

The user cannot give it away. They do not know the private key, they cannot read it off a screen, and the platform will not export it to a page. A phishing site can ask for anything it likes and there is nothing for the user to type.

The word "passkey" is a name for a WebAuthn credential presented in the way users now expect: discoverable, usually synced across a person's devices through their platform account, and unlocked with a fingerprint, a face or a device PIN. WebAuthn is the W3C specification underneath, and FIDO2 is the pair of it with CTAP2, the protocol a browser speaks to an external security key. The mechanics below are WebAuthn; passkey is what the product calls it.

The registration ceremony

Registration starts on the server, because the challenge must come from the server and be remembered.

const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
await session.set("challenge", challenge);   // server side, single use, short lived

const options = {
  challenge,
  rp: { id: "example.com", name: "Example" },
  user: {
    id: userHandleBytes,                     // opaque, not the email, up to 64 bytes
    name: "[email protected]",
    displayName: "Ada"
  },
  pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
  authenticatorSelection: { residentKey: "required", userVerification: "required" },
  attestation: "none",
  excludeCredentials: existing.map(c => ({ type: "public-key", id: c.rawId }))
};

const credential = await navigator.credentials.create({ publicKey: options });

Five of those fields decide whether the result is trustworthy.

challenge is at least 16 bytes and in practice 32, generated per attempt, stored server side, and consumed on use. A predictable or reused challenge lets an attacker replay a signature they captured earlier.

rp.id is the relying party identifier: your domain, and the credential is bound to it. It must be the current domain or a registrable suffix of it, so a page on app.example.com may set example.com and widen the credential to every subdomain, or set app.example.com and keep it narrow. Widening is a decision, not a default, and it puts every subdomain inside the credential's scope in the same way the cookie lesson's Domain attribute did.

user.id is an opaque byte string that the authenticator stores and hands back at login. It must not be an email address or anything else identifying, because it is written to the authenticator and may be visible in a credential picker on a shared device. Generate 16 random bytes per user and keep the mapping in your database.

pubKeyCredParams lists acceptable algorithms by COSE identifier, most preferred first. -7 is ES256, ECDSA over P-256 with SHA-256, which every authenticator supports. -257 is RS256, needed by some older platform authenticators. Listing both covers essentially everything.

authenticatorSelection carries the two choices that shape the product. residentKey: "required" asks for a discoverable credential, which is what makes usernameless sign-in possible. userVerification: "required" demands that the authenticator check a fingerprint, face or PIN, which is what makes the credential two factors in one gesture: something you have, the device, and something you know or are, the unlock. With userVerification: "preferred", an authenticator may return a credential with only a touch, which is one factor.

excludeCredentials stops a user registering the same authenticator twice, which otherwise produces a duplicate they cannot tell apart.

What comes back, byte by byte

The credential returned has a response containing clientDataJSON and attestationObject.

clientDataJSON is UTF-8 JSON assembled by the browser, not by the page:

{
  "type": "webauthn.create",
  "challenge": "p5aV2uHXr0AOqUk7HQitvi-Ma5-JfLGdxjmy2xkoLFA",
  "origin": "https://example.com",
  "crossOrigin": false
}

The origin field is the part that ends phishing, and it is worth being precise about why: the page cannot influence it. The browser writes the origin the script is actually running on, and the authenticator signs over the whole structure, so the server can see where the ceremony really happened.

attestationObject is CBOR containing fmt, attStmt and authData. The authData is a byte string with a fixed layout, and reading it by hand once is the fastest way to understand the protocol:

  • 32 bytes: rpIdHash, which is SHA-256 of the relying party identifier
  • 1 byte: flags
  • 4 bytes: signature counter, big-endian
  • then, only if the AT flag is set, the attested credential data: AAGUID, credential ID length, credential ID, and the public key in COSE format
  • then, only if the ED flag is set, extension outputs

So the minimum is 32+1+4=37 bytes. The flags byte carries six meanings in its bits: 0x01 UP, user present, meaning somebody touched it; 0x04 UV, user verified, meaning a biometric or PIN was checked; 0x08 BE, backup eligible; 0x10 BS, backup state, meaning it is currently backed up; 0x40 AT, attested credential data included; 0x80 ED, extension data included. A flags byte of 0x5d is 01011101, which reads as UP, UV, BE, BS and AT: a verified, synced passkey being registered.

BE and BS are how you tell a synced passkey from one confined to a single piece of hardware. If BE is clear, the credential cannot leave that device, so losing the device loses the credential and the account needs another way in.

Example. A registration arrives with a flags byte of 0x41 and your options asked for userVerification: "required". What does the byte say, and what should the server do?

0x41 is 01000001, so the bits set are 0x01 UP and 0x40 AT. The user touched the authenticator and attested credential data follows, which is expected for a registration. What is missing is 0x04 UV: no fingerprint, face or PIN was checked. The authenticator has returned a single-factor credential despite the request, which is permitted, since required can be refused by an authenticator that cannot do it. The server must reject the registration rather than store the credential, because storing it means later logins will present one factor while the code believes two were used. Tell the user their authenticator needs a PIN or biometric enabled.

Now you. An authentication response carries a flags byte of 0x1d. Read it, and say what you know about the credential.

Answer

0x1d is 00011101: UP 0x01, UV 0x04, BE 0x08 and BS 0x10. So the user was present and verified with a biometric or PIN, which is what a login should look like. AT is clear, correctly, since attested credential data appears only at registration. BE and BS together say the credential is backup eligible and currently backed up, meaning a synced passkey that exists on more than the one device. If your service requires a hardware-bound credential you would refuse this; for almost every service it is the desirable case, because the user will not be locked out by a dropped phone.

Authentication, and what is signed

Signing in uses the same shape with a different verb.

const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
await session.set("challenge", challenge);

const assertion = await navigator.credentials.get({
  publicKey: {
    challenge,
    rpId: "example.com",
    userVerification: "required",
    allowCredentials: []          // empty: let the browser offer discoverable credentials
  }
});

The response carries clientDataJSON with "type": "webauthn.get", an authenticatorData that is now the bare 37 bytes with no attested credential data, a signature, and a userHandle holding the user.id from registration.

What the authenticator signed is the one detail people get wrong when implementing this from scratch. It is not the challenge, and it is not the client data. It is

authenticatorDataSHA-256(clientDataJSON)

the raw authenticatorData bytes concatenated with the digest of the client data. Concatenating in the other order, or signing the client data directly, or hashing the whole thing before verifying, all produce a signature that never validates, and the error message will not tell you which.

Verifying it on the server

Verification is a checklist, and every line of it is load-bearing.

const clientData = JSON.parse(new TextDecoder().decode(response.clientDataJSON));

if (clientData.type !== "webauthn.get") throw new Error("wrong ceremony type");
if (clientData.origin !== "https://example.com") throw new Error("wrong origin");
if (!equalBytes(fromBase64url(clientData.challenge), storedChallenge)) throw new Error("bad challenge");

const authData = new Uint8Array(response.authenticatorData);
const expectedRpIdHash = new Uint8Array(
  await crypto.subtle.digest("SHA-256", new TextEncoder().encode("example.com"))
);
if (!equalBytes(authData.slice(0, 32), expectedRpIdHash)) throw new Error("wrong rpId");

const flags = authData[32];
if (!(flags & 0x01)) throw new Error("user not present");
if (!(flags & 0x04)) throw new Error("user not verified");

const clientDataHash = new Uint8Array(
  await crypto.subtle.digest("SHA-256", response.clientDataJSON)
);
const signed = new Uint8Array(authData.length + 32);
signed.set(authData, 0);
signed.set(clientDataHash, authData.length);

const ok = await crypto.subtle.verify(
  { name: "ECDSA", hash: "SHA-256" },
  storedPublicKey,
  rawSignature,
  signed
);

Three practical notes that cost people hours.

The origin comparison is exact string equality, not a suffix check. https://example.com and https://example.com:8443 are different origins, and endsWith("example.com") accepts https://evil-example.com, which is the whole attack.

The rpIdHash check is separate from the origin check and both are needed. The origin says where the page was; the rpIdHash says which relying party the authenticator believed it was signing for.

Web Crypto wants a raw signature and WebAuthn gives DER. An ES256 signature arrives ASN.1 DER encoded, typically 70 to 72 bytes, while crypto.subtle.verify expects the raw 64-byte concatenation of r and s. You must unwrap the DER structure, strip the leading zero bytes that DER adds to keep integers positive, and left-pad each of r and s to exactly 32 bytes. Skipping this gives a verification that fails on every input, and the usual reaction is to suspect the signature base, which is correct code.

Finally, the challenge is consumed here whether verification succeeded or failed, so a failed attempt cannot be retried against the same challenge.

Example. A user with a passkey for example.com is phished by a page on examp1e.com that is a pixel-perfect copy and calls navigator.credentials.get with a challenge it relayed from the real site. What happens?

Nothing usable, and it fails twice over. First, the browser will not offer the credential at all: the credential is scoped to the relying party example.com, and a page on examp1e.com cannot set rpId to a domain that is not a registrable suffix of its own, so the request either errors or finds no matching credential. Second, even supposing a signature were somehow produced, the browser would write "origin": "https://examp1e.com" into the client data, the authenticator would sign that, and the real server's exact-equality origin check would reject it. The user typed nothing, so there was nothing to give away.

Now you. A developer cannot get verification to pass and "fixes" it by changing the origin check to clientData.origin.endsWith("example.com"). What have they enabled?

Answer

They have accepted any origin whose host ends with those characters, including https://evil-example.com and https://example.com.attacker.net, both of which an attacker can register. That undoes the origin binding, which was the whole reason to use WebAuthn. Note that the browser's own rpId rule still stands in the way in most scenarios, so this is not immediately exploitable on its own, and it removes one of the two independent defences and leaves the design resting on a single check. The real bug is almost always the DER to raw signature conversion, and the correct fix is exact string equality against a fixed list of expected origins.

Discoverable credentials and signing in with no username

A non-discoverable credential stores nothing on the authenticator. The credential ID handed back at registration encodes the key, wrapped under a secret the authenticator holds, so the server must send that ID in allowCredentials for the authenticator to work out which key to use. That means the user has to identify themselves first, so the flow is username, then key.

A discoverable credential, formerly called a resident key, is stored on the authenticator with the relying party and user handle beside it. Now the server can send an empty allowCredentials, the browser shows the user which accounts it holds for this site, and the response comes back with userHandle saying who signed in. The user typed nothing at all: no username, no password.

That is what people mean by a passkey login, and it changes what your server must handle. Look up the account by userHandle rather than by a form field, be ready for a user with several passkeys, and keep a path for a user whose authenticator holds nothing for this site.

The cost is storage. Authenticators have limited room for discoverable credentials, particularly older hardware keys, which may hold only twenty-five or so. Platform authenticators backed by a cloud account do not have this problem in any practical sense.

Example. A site sets residentKey: "required" and userVerification: "required", and an auditor asks whether this counts as multi-factor authentication. What is the honest answer?

Yes, in one gesture, and it is worth being precise about the two factors. Something you have is the authenticator holding the private key, proven by a signature no other device can produce. Something you are or know is the biometric or PIN that unlocked it, reported by the UV flag. The server confirms the second by checking that flag, which is why userVerification: "preferred" would not do: with preferred, an authenticator may return UP without UV, and the server would be accepting a single factor while believing it had two. If UV matters, require it in the options and check the bit on arrival.

Now you. A synced passkey is backed up to the user's platform account. What does that do to the "something you have" claim?

Answer

It weakens it, honestly. The private key is now recoverable on any device the user signs into with that platform account, so "something you have" has become "something the platform account controls", and the account's own security becomes part of yours. In exchange the credential survives a dropped phone, which is what makes passkeys deployable to ordinary users at all. The BE and BS flags tell you which kind you have received, so a service with a genuine need for hardware-bound credentials can require BE to be clear and accept the recovery burden that follows.

Attestation, counters, and the honest limits

Attestation is the authenticator's statement about what it is, signed by a manufacturer key: this is a genuine YubiKey 5, this is Apple hardware. Requesting attestation: "direct" returns it, and verifying it means maintaining a trust store of manufacturer roots, typically the FIDO Metadata Service, and deciding which models you accept.

Almost no site should do this. It buys the ability to insist that credentials live on approved hardware, which matters to a government agency or a bank with a regulatory model list and to almost nobody else. It costs a trust store to maintain, a source of registration failures when a user's perfectly good authenticator is not on the list, and a privacy problem, since attestation carries an AAGUID identifying the model, which is a tracking vector the specification deliberately mitigates. Use attestation: "none" unless you can name the requirement.

The signature counter was designed as clone detection: the authenticator increments it on each assertion, the server stores the last value, and a value that fails to increase suggests two copies of the same credential in circulation. It has largely stopped working. Synced passkeys generally report zero every time, because a credential existing on several devices at once cannot maintain a single counter, and the specification permits it. So: if the stored counter and the new one are both zero, learn nothing; if the stored one is non-zero and the new value does not exceed it, that is a real signal worth acting on. Treat it as an occasionally useful alarm rather than a control.

Three more limits, since the point of this course is to name them.

Recovery is the hard part, and it is unchanged. A passkey removes the phishable secret and does not remove the need for a way back in when every device is lost. If that way back is an emailed link, the account's true strength is the mailbox, exactly as before. This is the single most common way a passkey deployment is undermined.

A stolen session is still a stolen session. WebAuthn authenticates the moment of login. Everything after it is the cookie from the third lesson, with all the same exposure to XSS. Passkeys do not make the earlier lessons unnecessary.

Device compromise is still device compromise. Malware on an unlocked machine can wait for the user to authenticate and then act inside the session. The private key is safe and the session is not.

What passkeys do is remove the phishable secret, which is the largest remaining category of real account takeover. That is a substantial win and not a complete one.

What this does not cover

Everything so far concerns credentials your site issues and verifies: passwords you store, sessions you mint, keys you register. There is another case entirely, which is a user who already has an account somewhere else and would rather not create one with you.

Delegating to a provider the user already trusts means letting a third party do the authenticating and tell you the result. That requires a protocol for asking, a way to be sure the answer came from the provider and was meant for you, and defences against an attacker who tampers with the round trip. That protocol is OAuth 2.0, with OpenID Connect on top, and it is next.