惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
人人都是产品经理
人人都是产品经理
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
T
The Exploit Database - CXSecurity.com
N
News and Events Feed by Topic
Latest news
Latest news
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
C
CXSECURITY Database RSS Feed - CXSecurity.com
IT之家
IT之家
V
V2EX
WordPress大学
WordPress大学
Apple Machine Learning Research
Apple Machine Learning Research
Cisco Talos Blog
Cisco Talos Blog
K
Kaspersky official blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
S
SegmentFault 最新的问题
小众软件
小众软件
A
Arctic Wolf
酷 壳 – CoolShell
酷 壳 – CoolShell
腾讯CDC
宝玉的分享
宝玉的分享
Last Week in AI
Last Week in AI
G
GRAHAM CLULEY
罗磊的独立博客
T
Tor Project blog
C
Cisco Blogs
美团技术团队
博客园 - Franky
月光博客
月光博客
博客园 - 三生石上(FineUI控件)
T
Threat Research - Cisco Blogs
Cyberwarzone
Cyberwarzone
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
有赞技术团队
有赞技术团队
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Security Latest
Security Latest
博客园 - 司徒正美
Hugging Face - Blog
Hugging Face - Blog
Spread Privacy
Spread Privacy
J
Java Code Geeks
C
CERT Recently Published Vulnerability Notes
大猫的无限游戏
大猫的无限游戏
S
Securelist
The Cloudflare Blog
博客园 - 叶小钗
D
Darknet – Hacking Tools, Hacker News & Cyber Security
阮一峰的网络日志
阮一峰的网络日志
雷峰网
雷峰网
Project Zero
Project Zero

Scott Helme

Connection Allowlist: a network firewall, built into the browser Top 1 Million Analysis – June 2026: The State of Crypto Top 1 Million Analysis – June 2026: Ten Years of Web Security A dead CDN, a wildcard, and an attack waiting to happen: the netdna-ssl.com takeover Why No Passkeys? Naming the Top Sites That Still Don't Support Them The Instructure Canvas Breach (2026): How XSS in a Support Ticket Compromised 275 Million Students Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP DBSC Beta at Report URI Device Bound Session Credentials: Making Stolen Cookies Useless Passkeys, Permissions Policy and Bug Hunting in 1Password's WebAuthn Wrapper Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP Passkeys 101: An Introduction to Passkeys and How They Work Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive Under Attack: Responding to the Rise of Info-Stealer Threats Security considerations when using Passkeys on your website Fighting an active Magecart Campaign Amazing Refresh — A Malicious Chrome Extension Running Malware in the Browser Bringing in the experts; Having our Passkeys implementation Security Tested Launching Passkeys support on Report URI! 🗝️ When “One in a Billion” Happens Every Day: Scaling Redis at Report URI Leverage our treasure trove of Threat Intelligence data XSS Ranked #1 Top Threat of 2025 by MITRE and CISA DNS-PERSIST-01; Handling Domain Control Validation in a short-lived certificate World
XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None
Scott Helme · 2026-05-19 · via Scott Helme

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.

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

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.

We also need to establish some terminology to make the rest of this blog post easier to understand:

Relying Party: The website or application that stores and verifies a user's passkey credential for authentication.

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.

Attestation: The mechanism an Authenticator can use during registration to prove what kind of hardware created the credential.

How Passkey Registration Works

When registering a passkey with an RP like Report URI, JavaScript will make a call out to fetch the data it needs:

const optRes = await fetch('/passkeys/register_get_options/' + getCsrfToken(), { method: 'POST' });
POST /passkeys/register_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
Host: report-uri.com
Cookie: session=...
Content-Length: 0

The RP will return a response that looks like this and contains the publicKey object:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "publicKey": {
    "rp": {
      "name": "Report URI",
      "id": "report-uri.com"
    },
    "user": {
      "id": "Yi8kP1xqd0Jx3mWZ8Q2vK7nR4tH6sLpA9dF1gE0wXc=",
      "name": "jane@example.com",
      "displayName": "jane@example.com"
    },
    "challenge": "kQ7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J",
    "pubKeyCredParams": [
      { "type": "public-key", "alg": -8 },
      { "type": "public-key", "alg": -7 },
      { "type": "public-key", "alg": -257 }
    ],
    "timeout": 60000,
    "authenticatorSelection": {
      "requireResidentKey": true,
      "residentKey": "required",
      "userVerification": "required"
    },
    "attestation": "none",
    "excludeCredentials": [
      {
        "type": "public-key",
        "id": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc...",
        "transports": ["usb", "nfc", "ble", "hybrid", "internal"]
      }
    ]
  }

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:

const cred = await navigator.credentials.create({ publicKey });

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:

const payload = {
    name: nameInput?.value?.trim() || '',
    password: passwordInput.value,
    id: cred.id,
    rawId: cred.rawId,
    type: cred.type,
    clientDataJSON: cred.response.clientDataJSON,
    attestationObject: cred.response.attestationObject,
};

const finRes = await fetch('/passkeys/register_finish/' + getCsrfToken(), {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
});

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:

attestationObject (CBOR)
├─ fmt                       ← attestation format, e.g. "none" / "apple"
├─ authData                  ← authenticator data
│  ├─ rpIdHash               ← SHA-256 hash of the RP ID
│  ├─ flags                  ← UP/UV/AT/ED flags, etc.
│  ├─ signCount              ← signature counter
│  └─ attestedCredentialData
│     ├─ aaguid              ← type/model id, not useful for synced passkeys
│     ├─ credentialIdLength
│     ├─ credentialId        ← credential is, also surfaced as id/rawId
│     └─ credentialPublicKey ← COSE-encoded public key
└─ attStmt                   ← attestation statement; empty for fmt "none"

The RP can now save the public key against the user and we know that this is a passkey they will be able to use to authenticate in the future. The stored record might look something like this:

{
    "id": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc",
    "name": "Jane's MacBook",
    "pem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\n-----END PUBLIC KEY-----\n",
    "counter": 0,
    "created": "2026-05-16T14:22:07+00:00"
}

How Passkey Authentication Works

The process for logging in is equally as simple, with only a couple of steps to successfully authenticate with a passkey. First, the JavaScript must fetch the information required to authenticate from the RP.

const optRes = await fetch('/passkeys/login_get_options/' + getCsrfToken(), { method: 'POST', credentials: 'same-origin' });
POST /passkeys/login_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
Host: report-uri.com
Cookie: session=...
Content-Length: 0

The RP will respond with a publicKey object that contains the required information:

HTTP/1.1 200 OK
Content-Type: application/json
{
  "publicKey": {
    "challenge": "Vk7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J",
    "timeout": 20000,
    "rpId": "report-uri.com",
    "userVerification": "required",
    "allowCredentials": [
      {
        "id": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc",
        "type": "public-key",
        "transports": ["usb", "nfc", "ble", "hybrid", "internal"]
      }
    ]
  }
}

You must have some way of telling the RP which user/account is trying to login, and Report URI rely on the user already having completed their email address and password in the first step, but some websites will just ask for your email address. The response that came back from the RP has to have looked up the user's account, jane@example.com in this case, and now provides a list of allowCredentials which are the id values of previously registered passkeys. If you look in the earlier registration steps you can see that we registered a passkey with the id value AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc and this has now been returned to us during login as an allowed credential. We can now pass this to the Authenticator using the following JavaScript API call :

const assertion = await navigator.credentials.get({ publicKey });

At this point, your Authenticator might ask you for a PIN, FaceID, TouchID or similar, and then the Authenticator is going to sign the challenge with the associated private key it stored earlier during registration, identified using the id provided. This signed challenge can then be returned to the RP to demonstrate possession of the private key:

const payload = {
    id: assertion.id,
    rawId: assertion.rawId,
    type: assertion.type,
    clientDataJSON: assertion.response.clientDataJSON,
    authenticatorData: assertion.response.authenticatorData,
    signature: assertion.response.signature,
    userHandle: assertion.response.userHandle || '',
};

const finRes = await fetch('/passkeys/login_finish/' + getCsrfToken(), {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
});
POST /passkeys/login_finish/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
Host: report-uri.com
Content-Type: application/json
Cookie: session=...

{
  "id": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc",
  "rawId": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc==",
  "type": "public-key",
  "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVms3blI0dEg2c0xwQTlkRjFnRTB3WGMydks3bVo4UTJZaThrUDF4cWQwSiIsIm9yaWdpbiI6Imh0dHBzOi8vcmVwb3J0LXVyaS5jb20iLCJjcm9zc09yaWdpbiI6ZmFsc2V9",
  "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA==",
  "signature": "MEUCIQD3...base64 of the ECDSA/EdDSA signature...AiEA9k2m",
  "userHandle": "T3xq2mP9kZ8Q2vK7nR4tH6sLpA9dF1gE0wXcYi8kP1w="
}

If the RP can then successfully verify the signature in this payload using the public key it stored during registration, the user trying to log in has proven possession of the private key that's associated with the stored public key. This means they have now completed authentication with a passkey and you can grant them access to the account.

Understanding Attestation

Attestation is a pretty big deal, but if you go back and look at the registration process when the client called out to /passkeys/register_get_options, you will notice the following in the response sent back by the RP:

{
  ...
  "attestation": "none",
  ...
}

Attestation allows your application, the RP, to answer the question 'what kind of authenticator am I working with', and it's answering that question at a hardware level and getting an answer it can verify. That sounds great, so why is Report URI not requiring that?

In order for attestation to work, you would first need to get the certificates of all registered authenticators that can produce passkeys. You can grab that information from the FIDO Alliance as part of their Metadata Service (MDS3), and it's just a case of downloading the file and verifying its signature, and then parsing out all of the certificates. You need to do this ~once per month to stay current, and then you can ask for attestation when an authenticator is registering a passkey with your application.

Attestation is then a signature from the authenticator proving that it's a genuine authenticator from a particular manufacturer, let's say a YubiKey. Our application can verify that signature using the certificates that we fetched above and then we can be confident that we're dealing with a genuine YubiKey. The authenticator will provide an attestationObject that contains an attStmt that looks like this during the registration flow:

"attStmt": {
  "alg": -7,                // COSE alg of the signature (e.g. -7 = ES256)
  "sig": h'3045022100…',    // sig over (authData ‖ SHA-256(clientDataJSON))
  "x5c": [                  // attestation certificate chain, leaf first
    h'308202bd30820…',      // leaf: the authenticator's attestation cert
    h'30820336308…'         // (optional) intermediate CA cert(s)
  ]
}

So why on Earth would we not require attestation when registering a passkey with our application?

Convenience

The trade-off nobody mentions! An authenticator's ability to cryptographically prove what kind of hardware device it is can't be explained as anything other than a major security win. But that win does come at a cost.

We pulled the current MDS3 list to take a look at what's in there and we see the likes of Yubico, Feitian, Thales, Ledger, the platform TPM/Hello authenticators, and many more. The problem is what we didn't see. 1Password, LastPass, Bitwarden, Dashlane, iCloud Keychain, Google Password Manager, Chrome's built-in store... This isn't an oversight from these companies, it's a design choice.

The original idea behind passkeys was that the private key would remain locked on a single device, in secure storage like the Secure Enclave, a TPM, or similar. I'd register a passkey against my online account and save it as "Scott's Laptop", and that passkey would forever remain on my laptop, securely stored in the TPM (I'm on Windows). This is a tremendous security super-power, but it comes with a trade-off. If I were to lose my laptop, spill a coffee on it, or it failed spectacularly and the magic smoke got out, I'm in big trouble. I'd now need to have another device somewhere else that already had a passkey registered on my account so I could sign in from that device, otherwise I'm in big trouble. This idea of having to register and manage individual passkeys for each of your devices to be able to access your online account is what drove us in another direction.

Synced Passkeys

Synced passkeys are the architectural polar-opposite of an attestable hardware credential. Instead of storing the passkey in a secure storage medium like the Secure Enclave or TPM, I use 1Password, which stores the private key in my 1Password vault. My vault is then synced across all of my devices, my Windows desktop, my iPhone, my MacBook Pro, my iPad, and more. This offers me a huge amount of convenience because I can register a passkey with an RP a single time, and then login with that passkey across all of my devices, instead of having to register a passkey from each and every device. But that's the rub... We can't have meaningful hardware attestation in this process to tell us what type of hardware Authenticator we're dealing with because the answer to the question 'what type of device is this?' will always be 'it depends'. There's no generally useful way for software Authenticators like 1Password and others to do attestation, and this is why we don't require it on Report URI, because if we did, the vast majority of our users wouldn't be able to use their preferred method for registering and authenticating with passkeys.

That tension — device attestation vs. synced passkeys — is genuinely the crux of this whole blog post.

Where It All Falls Apart

We now have all the pieces of the puzzle, so let's put this together and see where it falls apart. Most online services are not going to require Attestation because it would force so many of their users out of being able to use passkeys in their preferred way. But Attestation allows the RP to know that it's talking to a bona fide Authenticator backed by hardware. Without Attestation we're just talking to software, we're talking to code. As it turns out, webpages run code...

The entire passkey registration and authentication flows that we walked through earlier were driven by JavaScript. To register a new passkey the page will call navigator.credentials.create() and interact with the Authenticator, passing data backwards and forwards. To authenticate with a passkey the page will call navigator.credentials.get() and interact with the Authenticator, passing data backwards and forwards. If we take Attestation out of the picture, you can complete this entire flow in JavaScript without ever even having to involve an Authenticator. Let's walk through it:

  1. The JavaScript calls /passkeys/register_get_options/ to begin the registration flow as normal.

  2. Typically, the JavaScript would now call navigator.credentials.create() to create the new public/private key pair in the Authenticator, instead, we're going just going to create a new key pair in JavaScript.

    const kp = await crypto.subtle.generateKey(
        { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']
    );
    
  3. We now need to build the payload to send to /passkeys/register_finish/ which requires the public key that we just generated, and no attestation data is required. The RP will later only be able to verify that logins are signed by the private key corresponding to this submitted public key; it has not verified that the key was created inside a 'real' authenticator.

  4. A new passkey has been successfully registered on the user's account with absolutely no user interaction required.

This might sound crazy, that by simply visiting a page running malicious JavaScript it can register a passkey on your account with absolutely no interaction, but that's exactly how it works if no other steps are required.

To prove this, I built a few demo pages on the Report URI Demo Site, and specifically you want to look at Passkeys Demo 1 for this. The very moment that page loads in your browser, the JavaScript payload is going to register a passkey on your account. You can register your own passkey as normal and even sign in with your own passkey, give it a try, but there will always be that second passkey registered by the malicious JavaScript and owned by the attacker.

XSS Is Now Deadly

Having an attacker register their own passkey on your account is a particularly nasty form of account takeover. It is persistent, it looks like a legitimate account-security change, and if passkeys are sufficient to sign in, the attacker now has a clean authentication path back into the account. You are now totally pwned, and, it gets worse.

Because the passkey registration process is orchestrated by JavaScript, if we're running malicious JavaScript in the page, we can proxy the WebAuthn API calls between the browser and the Authenticator. The ultimate in-page MiTM attack!

By hooking and tampering with the navigator.credentials.create() API, we can substitute the values being passed between the browser and the Authenticator. This means that the user will conduct their normal registration process, get a prompt from their Authenticator to create and save a new passkey, but the Authenticator will then save the wrong passkey. The Authenticator will save the passkey that it generated, but that was not the passkey sent to the RP, which was substituted for the attacker's passkey. It now looks like you've registered a passkey on the website, you see a passkey in your password manager, the website shows that a Passkey has now been registered on your account, but the passkey the victim has will never work. Only the passkey that the attacker has will work and the reason this work so well is best demonstrated by updating the diagram.

To demonstrate this process, we've created Passkeys Demo 2, where you can register a passkey on your account, but the passkey saved on your device will not be the correct passkey. You can then try to sign in with your passkey and observe that, as expected, it doesn't work, but the attacker can log in with their passkey.

The Threat Model That Matters

Attestation isn't being "skipped" out of laziness or a lack of knowledge, it's a recognition that for a service whose users are spread across every device and every password manager, the strong version of attestation would trade an assurance about device provenance for a very real loss of accessibility. The threat model that matters for us — phishing, credential theft, replay — is fully addressed by the challenge/origin binding and the signature check provided by synced passkeys. Device attestation doesn't move that needle, and it's why we don't require it.

Attestation and synced passkeys are fundamentally at odds, and choosing not to attest is what lets your users bring the passkeys that they actually have. If it's a choice between no Attestation or no passkeys, which are you choosing?

Defending Against The Threat

Everyone out there should be using, or aiming to use, passkeys, but we need to acknowledge the risks that exist and take steps to mitigate them. Here is some practical guidance to take away and use to help strengthen your passkeys deployment.

Step up authentication before registration

This one can be tricky because I've seen many sites using passkeys to replace passwords, but that's not something we've done on Report URI, passkeys are used as a 2FA mechanism. When attempting to register a new passkey on your account, you need the current password for the account to do it. This means that JavaScript can't silently register a new passkey. You could also require any other 2FA mechanism, a magic-link via email, or any other additional authentication mechanism.

Stop the Malicious JavaScript from running

A strong Content Security Policy is going to go a long way here and the best way to stop this attack is to stop the XSS at the source. You should also use Subresource Integrity wherever possible to secure your third-party dependencies. You can see Passkeys Demo 3 for what happens when an analytics script goes rogue and starts registering passkeys for your visitors.

Take Control of Powerful APIs

Using Permissions Policy, you can take control of which pages on your site, and which third-party scripts you're loading, have access to the navigator.credentials.create() and navigator.credentials.get() API calls to register a passkey and authenticate with a passkey. In reality, we probably have very few pages on our sites that need to touch passkeys, and probably even fewer third-party scripts that we want to have that capability. This won’t stop the direct /register_finish/ attack described above, because that attack doesn’t need the WebAuthn API, but it does reduce the number of places where malicious JavaScript can interfere with legitimate passkey ceremonies.

Out-Of-Band Notification on Registration

If a new passkey is added to the account of one of your users, you should absolutely be notifying them that this has happened. Send out a notification, via email or any other means, to your user as soon as a new passkey is added to their account. If they were not expecting this to have happened, they can take immediate steps to protect their account.

These Are Problems That Report URI Can Solve

As a specialised client-side protection platform, it stands to reason that Report URI can help you defend against these client-side attacks. I'm going to keep it brief here as the main purpose of this blog post is to highlight the risks above, but this is a topic we've done a lot of research on and we can provide some real value.

  • Get a Content Security Policy deployed and get real-time feedback from the browser about what's happening in the page as your visitors see it.
  • Use our JavaScript Integrity Monitoring to keep track of your third-party JavaScript dependencies, and when they change.
  • Audit the use of Subresource Integrity across your site using Integrity Policy and keep your JavaScript Supply Chain secure.
  • Deploy a Permissions Policy on your site and lock down the use of powerful JavaScript APIs.

We’ve also put together a dedicated Passkeys solutions page and whitepaper for teams who want practical guidance on finding and reducing these risks.

Conclusion

Using attestation: "none" isn't a problem, it's a trade-off between security and convenience. The hidden risk is overlooking the threat of a page-level adversary, who is always going to cause you problems, but they can cause some particularly big problems when it comes to passkeys.

Passkeys remain the right direction, and I want to see widespread adoption of them, but the security boundary they replace (passwords) was a single secret on the wire. The boundary they introduce, a ceremony brokered by the user agent, only holds if the user agent, and everything injected into it, can be trusted. This is why XSS becomes deadly to passkeys.


Have you enjoyed this post or found it helpful?
☕️ Consider buying me a coffee to say thanks!
🔔 Subscribe for free notifications when I publish!
🤩 Become a member and support my content!

Tags: XSS, Passkeys, Report URI