XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None

moebrowne1 pts0 comments

XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None

Sponsored by: Report URI - Your website is running code you don't control. See it. Control it. Prove it.

A single XSS vulnerability can turn passkeys from a phishing-resistant login mechanism into a persistent account takeover backdoor. If malicious JavaScript can run on your page, it may be able to register an attacker-controlled passkey against the victim’s account. The user sees nothing, the website records a successful registration, and the attacker walks away with a valid authentication backdoor.

For an organisation, that means more than “someone found XSS”. It means identity compromise, persistence, audit-trail ambiguity, regulatory exposure, and a security control that appears to have worked while silently enabling an attacker.<br>The uncomfortable truth is that while passkeys do bring amazing benefits, and I think that everyone should use them, there is a dangerous gap in the threat model that's being overlooked by almost everyone I speak to. This blog post explains the risk, demonstrates how this is possible, and what the effective defences look like.

Introduction<br>Before we get started, if you'd like a brief overview of how passkeys work, you can jump over to my Passkeys 101 blog post, where I explain the basics. I'm going to assume in this blog post that you understand the concept of passkeys, and we're going to look at how they work in more detail in this post.<br>We also need to establish some terminology to make the rest of this blog post easier to understand:<br>Relying Party : The website or application that stores and verifies a user's passkey credential for authentication.<br>Authenticator : The user’s device or password manager that creates, stores, and uses the private key to prove the user’s identity to the Relying Party.<br>Attestation : The mechanism an Authenticator can use during registration to prove what kind of hardware created the credential.

How Passkey Registration Works<br>When registering a passkey with an RP like Report URI, JavaScript will make a call out to fetch the data it needs:<br>const optRes = await fetch('/passkeys/register_get_options/' + getCsrfToken(), { method: 'POST' });POST /passkeys/register_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1<br>Host: report-uri.com<br>Cookie: session=...<br>Content-Length: 0<br>The RP will return a response that looks like this and contains the publicKey object:<br>HTTP/1.1 200 OK<br>Content-Type: application/json

"publicKey": {<br>"rp": {<br>"name": "Report URI",<br>"id": "report-uri.com"<br>},<br>"user": {<br>"id": "Yi8kP1xqd0Jx3mWZ8Q2vK7nR4tH6sLpA9dF1gE0wXc=",<br>"name": "jane@example.com",<br>"displayName": "jane@example.com"<br>},<br>"challenge": "kQ7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J",<br>"pubKeyCredParams": [<br>{ "type": "public-key", "alg": -8 },<br>{ "type": "public-key", "alg": -7 },<br>{ "type": "public-key", "alg": -257 }<br>],<br>"timeout": 60000,<br>"authenticatorSelection": {<br>"requireResidentKey": true,<br>"residentKey": "required",<br>"userVerification": "required"<br>},<br>"attestation": "none",<br>"excludeCredentials": [<br>"type": "public-key",<br>"id": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc...",<br>"transports": ["usb", "nfc", "ble", "hybrid", "internal"]<br>Now that your device has the information it needs, it can create the new passkey and save it, likely showing you some kind of confirmation that requires a PIN, FaceID, TouchID, etc... This is done with the following JavaScript API call that will trigger the interaction with your Authenticator:<br>const cred = await navigator.credentials.create({ publicKey });<br>If you complete the process, your Authenticator will then store your new passkey. The JavaScript will then build the response to send back to the RP to confirm that everything has been completed and to save the new passkey against the user's account:<br>const payload = {<br>name: nameInput?.value?.trim() || '',<br>password: passwordInput.value,<br>id: cred.id,<br>rawId: cred.rawId,<br>type: cred.type,<br>clientDataJSON: cred.response.clientDataJSON,<br>attestationObject: cred.response.attestationObject,<br>};

const finRes = await fetch('/passkeys/register_finish/' + getCsrfToken(), {<br>method: 'POST',<br>headers: { 'Content-Type': 'application/json' },<br>body: JSON.stringify(payload),<br>});<br>The attestationObject contains the important information, with everything else being mostly metadata. Here's the content of the attestationObject with the public key being the crucial part:<br>attestationObject (CBOR)<br>├─ fmt ← attestation format, e.g. "none" / "apple"<br>├─ authData ← authenticator data<br>│ ├─ rpIdHash ← SHA-256 hash of the RP ID<br>│ ├─ flags ← UP/UV/AT/ED flags, etc.<br>│ ├─ signCount ← signature counter<br>│ └─ attestedCredentialData<br>│ ├─ aaguid ← type/model id, not useful for synced passkeys<br>│ ├─ credentialIdLength<br>│ ├─ credentialId ← credential is, also surfaced as id/rawId<br>│ └─ credentialPublicKey ← COSE-encoded public key<br>└─ attStmt ← attestation statement; empty for fmt "none"<br>The RP can now save the public key against the user and we know that this is a passkey they will be...

passkeys type passkey post user public

Related Articles