RSS/Atom Feed Analyzer
Analysis of https://scotthelme.co.uk/rss/
Feed fetched in 101 ms.
Warning Content type is application/rss+xml; charset=utf-8, not text/xml or applicaton/xml.
Feed is 298,574 characters long.
Feed has an ETag of W/"48bdf-bSnjLNreedU3OH2z9mdcRcXPiUM".
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
Feed self link matches feed URL.
Feed has an image at https://scotthelme.co.uk/favicon.png.
Feed has 15 items.
First item published on 2026-09-07T09:13:49.000Z
Last item published on 2026-05-21T14:40:02.000Z
All items have published dates.
Newest item was published on 2026-09-07T09:13:49.000Z.
Home page URL: https://scotthelme.co.uk/
Home page has feed discovery link in <head>.
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.co.uk/</link>
<image>
<url>https://scotthelme.co.uk/favicon.png</url>
<title>Scott Helme</title>
<link>https://scotthelme.co.uk/</link>
</image>
<generator>Ghost 6.64</generator>
<lastBuildDate>Fri, 11 Sep 2026 01:47:50 GMT</lastBuildDate>
<atom:link href="https://scotthelme.co.uk/rss/" rel="self" type="application/rss+xml"/>
<ttl>60</ttl>
<item>
<title><![CDATA[No Hacking Required: The Manchester Airports Group Data Breach]]></title>
<description><![CDATA[<p>On 27 August 2026, Manchester Airports Group told customers that "an unauthorised third party" had stolen their data. Car park bookings, lounge bookings, Fast Track purchases for airport security and passport control, along with airport WiFi sign-ups across Manchester, Stansted and East Midlands. Roughly 8.8 million</p>]]></description>
<link>https://scotthelme.co.uk/no-hacking-required-manchester-airports-group-data-breach/</link>
<guid isPermaLink="false">6a9b30371c91af0001bd2b4f</guid>
<category><![CDATA[FulcrumSec]]></category>
<category><![CDATA[MAG]]></category>
<category><![CDATA[javascript]]></category>
<dc:creator><![CDATA[Scott Helme]]></dc:creator>
<pubDate>Mon, 07 Sep 2026 09:13:49 GMT</pubDate>
<media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/mag.png" medium="image"/>
<content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/mag.png" alt="No Hacking Required: The Manchester Airports Group Data Breach"><p>On 27 August 2026, Manchester Airports Group told customers that "an unauthorised third party" had stolen their data. Car park bookings, lounge bookings, Fast Track purchases for airport security and passport control, along with airport WiFi sign-ups across Manchester, Stansted and East Midlands. Roughly 8.8 million people.</p><p>A few days later, the group calling itself FulcrumSec claimed it, and said something that caught my eye. They said they didn't hack anything. They said they simply read an API key out of the website's JavaScript.</p><p>That's a very specific, very checkable claim. So I checked it.</p><p>Everything they described was there. Three keys, one per airport, in the page source, unrotated for over four years, for anyone to see.</p><p></p><h2 id="what-mag-said">What MAG said</h2><p>MAG's <a href="https://www.manchesterairport.co.uk/help/data-security-incident/?utm_source=scotthelme.co.uk">incident page</a> is what you'd expect. Email addresses, phone numbers, vehicle registrations and postcodes were taken. No bank or payment details. The incident was "immediately contained", specialist advisors were engaged, authorities were notified, and at no point was passenger safety or aviation security 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/09/MAG_Manchester_Airport_logo.svg" class="kg-image" alt="No Hacking Required: The Manchester Airports Group Data Breach" loading="lazy" width="186" height="79"></figure><p></p><p>What it doesn't say is <em>how</em>.</p><p>That's not unusual, and I'm not going to beat them up for it, but when the attacker makes a technical claim and the victim says nothing, the claim stands unchallenged, and I'd rather know whether it's true.</p><p></p><h2 id="what-fulcrumsec-said">What FulcrumSec said</h2><p><a href="https://fulcrumsec.vg/mag/?utm_source=scotthelme.co.uk" rel="noreferrer">Their claim</a>, as reported, was that they "obtained access using airport-specific Iterable API credentials exposed in client-side JavaScript." The first few times I read that, I read it as "iterable", as in it could be iterated/enumerated. Not as "Iterable", the brand!</p><p></p><ol><li><strong>Iterable</strong> — a marketing automation platform. Customer profiles, segmentation, campaign history.</li><li><strong>Airport-specific</strong> — plural credentials, one per airport, not one shared key.</li><li><strong>Client-side JavaScript</strong> — shipped to the browser. Public by construction.</li></ol><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/09/fulcrum-sec.png" class="kg-image" alt="No Hacking Required: The Manchester Airports Group Data Breach" loading="lazy" width="2000" height="667" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/09/fulcrum-sec.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/09/fulcrum-sec.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/09/fulcrum-sec.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/fulcrum-sec.png 2172w" sizes="(min-width: 720px) 720px"></figure><p></p><p>If all three are true, this isn't a breach in the way people picture one. There's no intrusion, no lateral movement, no malware. It's someone opening DevTools, good old 'F12 is hacking' stuff. </p><p></p><h2 id="where-to-look">Where to look</h2><p>MAG's three airport sites are Next.js applications served from CloudFront:</p><pre><code>www.manchesterairport.co.uk → man.live.mag-dxp.com
www.stanstedairport.com → stn.live.mag-dxp.com
www.eastmidlandsairport.com → ema.live.mag-dxp.com
</code></pre><p></p><p>I started with the obvious thing: curl the homepage and grep it. Nothing. The HTML mentions Iterable, but only as CMS field names like <code>iterableEventName</code>, <code>trackWithIterable</code>, <code>iterableCampaignId</code>. Configuration for which marketing event a signup form fires. No credentials. Same story in <code>__NEXT_DATA__</code>.</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/09/iterable-logo-auto.svg" class="kg-image" alt="No Hacking Required: The Manchester Airports Group Data Breach" loading="lazy" width="135" height="20"></figure><p></p><p>This is the tricky thing with problems like this; if you only look at the HTML, you find nothing. The secret isn't in the DOM. It's in a JavaScript bundle the document loads, one of about ninety chunk files with content-hashed names, and people don't often read those. Not the developer who shipped it, not the reviewer, and not whatever scanner MAG was running, clearly.</p><p></p><p>So I pulled all ninety and grepped those instead. Seventeen of them mention Iterable, and this is what's in them:</p><pre><code class="language-js">function (e) {
let t = `${n?.IterableApi?.Url}/api/users/${e}`;
return r.A.get(t, { headers: { "Api-Key": n?.IterableApi?.Key } })
.then(e => {
e.data.user && window.sessionStorage.setItem("IterableUID", e.data.user.userId)
})
}
</code></pre><p>And alongside it:</p><pre><code class="language-js">function (e) {
let t = `${n?.IterableApi?.Url}/api/events/track`;
return r.A.post(t, e, { headers: { "Api-Key": n?.IterableApi?.Key } })
}
</code></pre><p></p><p>There it is. The browser makes a <code>GET</code> to <code>https://api.iterable.com/api/users/{email}</code> with an <code>Api-Key</code> header, and gets back a user object.</p><p>That first call looks really bad. It takes an email address and returns that person's profile. It's a server-side credential doing a server-side job, from inside the browser on the client-side, on a normal page load, for anyone who cared to look.</p><p>The config object that feeds it lives in <code>pages/_app-*.js</code>:</p><pre><code class="language-json">"IterableApi": { "Key": "", "Url": "https://api.iterable.com" }
</code></pre><p></p><p>It's now empty. Which tells you two things: the mechanism is exactly what FulcrumSec described, and somebody has since removed the key.</p><p>An empty string is not evidence of what used to be there, though. For that I needed the history. </p><p></p><h2 id="the-internet-never-forgets">The Internet never forgets</h2><p>The Internet Archive's CDX API indexes far more than homepages. It has JavaScript, and MAG's <code>_app</code> chunk has been captured for years:</p><pre><code class="language-bash">curl "http://web.archive.org/cdx/search/cdx?\
url=manchesterairport.co.uk&matchType=domain&output=text\
&fl=timestamp,original,statuscode,digest\
&filter=original:.*chunks/pages/_app-.*\
&filter=statuscode:200&collapse=digest"
</code></pre><p></p><p>393 unique captures for Manchester going back to 2020, along with 445 for Stansted, and 208 for East Midlands.</p><p>Fetch any one of them with the <code>id_</code> modifier, which serves the original bytes rather than Wayback's rewritten version, decompress it, and grep away:</p><pre><code class="language-bash">curl -s "https://web.archive.org/web/20260802022711id_/\
https://www.manchesterairport.co.uk/_next/static/chunks/pages/_app-6c91337bee847d36.js" \
| python3 -c "import sys,brotli,re; \
s=brotli.decompress(sys.stdin.buffer.read()).decode(); \
print(re.search(r'\"IterableApi\":\{[^}]*\}', s).group(0))"
</code></pre><pre><code class="language-json">"IterableApi": { "Key": "5273…517c", "Url": "https://api.iterable.com" }
</code></pre><p></p><p>A 32-character hexadecimal Iterable API key, in a file Manchester Airports Group had been <strong>serving to the public since 2022</strong>.</p><p></p><h2 id="three-keys-over-four-years">Three keys, over four years</h2><p>I sampled every quarter from 2020 to today across all three domains, then dug deeper to find the exact dates. Here's the timeline.</p><p></p><table>
<thead>
<tr>
<th>Site</th>
<th>Key</th>
<th>Absent</th>
<th>First seen exposed</th>
<th>Last seen exposed</th>
<th>Emptied by</th>
</tr>
</thead>
<tbody>
<tr>
<td>East Midlands</td>
<td><code>c72b…0086</code></td>
<td>2022-06-05</td>
<td><strong>2022-06-23</strong></td>
<td>2026-08-16</td>
<td>no capture after</td>
</tr>
<tr>
<td>Stansted</td>
<td><code>5969…ccf7</code></td>
<td>2022-06-16</td>
<td><strong>2022-06-28</strong></td>
<td>2026-08-23</td>
<td><strong>2026-08-25 20:48</strong></td>
</tr>
<tr>
<td>Manchester</td>
<td><code>5273…517c</code></td>
<td>2022-07-03</td>
<td><strong>2022-07-11</strong></td>
<td>2026-08-22</td>
<td>2026-08-27 21:49</td>
</tr>
</tbody>
</table>
<p></p><p>Three distinct keys, one per airport, just as FulcrumSec had said, it was "airport-specific".</p><p>The keys appear during a rollout in June and July 2022, and then <strong>the exact same value is present in every single capture until August 2026</strong>. They were <em>never</em> rotated. Not once, in more than four years.</p><p>Read the timeline the other way round and it's worse. Anyone who looked at that page source on any day between June 2022 and August 2026 could have taken the key. FulcrumSec just happen to be the ones who told us. There is no way to know, from the outside, who else did, and the honest answer is that MAG can't know either without going back through four years of Iterable API logs, if they even have four years of Iterable API logs.</p><p>One detail that does stand out in this, though: Stansted's key went empty by 25 August,<strong> at least two days before MAG went public.</strong> That sounds like a normal incident response, find it, kill it, then disclose, but it does put a date on when they knew.</p><p>For East Midlands, the Internet Archive has no capture of that chunk after 16 August, so I can't precisely date its remediation. It was empty when I looked, though, and that we can prove.</p><p></p><h2 id="what-the-key-could-actually-do">What the key could actually do</h2><p>Having been a MAG customer many times myself, I'm in the breach many times over, as confirmed by <a href="https://haveibeenpwned.com/?utm_source=scotthelme.co.uk" rel="noreferrer">HIBP</a>, so I've had these API keys in my browser and sent these exact requests many times before! All you had to do was visit the right page and your browser would send the request as intended. </p><p></p><pre><code class="language-bash">scott@Gaming-Rig:~/code$ curl -s "https://api.iterable.com/api/users/mag%40scotthelme.co.uk" -H "Api-Key: 527snip17c" | jq .
{
"msg": "Disabled API key",
"code": "Unauthorized",
"params": {
"ip": "snip",
"endpoint": "/api/users/mag%40scotthelme.co.uk"
}
}
scott@Gaming-Rig:~/code$ curl -s "https://api.iterable.com/api/users/mag%40scotthelme.co.uk" -H "Api-Key: 596snipcf7" | jq .
{
"msg": "Disabled API key",
"code": "Unauthorized",
"params": {
"ip": "snip",
"endpoint": "/api/users/mag%40scotthelme.co.uk"
}
}
scott@Gaming-Rig:~/code$ curl -s "https://api.iterable.com/api/users/mag%40scotthelme.co.uk" -H "Api-Key: c72snip086" | jq .
{
"msg": "Disabled API key",
"code": "Unauthorized",
"params": {
"ip": "snip",
"endpoint": "/api/users/mag%40scotthelme.co.uk"
}
}</code></pre><p></p><p>I'm glad to see that the keys are disabled at least, it means I don't have to reach out to Iterable to disclose and that the breach did stop when the keys were pulled from the production site.</p><p>You didn't need curl to test it anyway. The site's own code calls <code>GET /api/users/{email}</code> with this key and reads <code>data.user.userId</code> off the response. That path returns a user's profile. So the key had, at minimum, read access to customer records by email address.</p><p>Given that, the rest follows without any further cleverness. Feed it a list of addresses and you're enumerating a customer database over HTTPS from a coffee shop. FulcrumSec described roughly 86GB compressed, extracting to around 640GB, including a 21.5GB Manchester customer export and about 200,000 records covering upcoming travel in 2026 with dates, times and booking references tied to names.</p><p>That last category is the one that actually worries me. Email addresses leak and life goes on, sadly it's a daily thing now. "This named person, with this car registration, is flying on this date" is a different kind of breach, though, and to their limited credit FulcrumSec appeared to recognise it, saying they might redact forward travel records.</p><p>But going back to that previous point, something didn't sit right with me. I use custom email addresses per service, and the MAG breach was no different: <code>mag@scotthelme.co.uk</code></p><p>How on Earth did they enumerate that?</p><p></p><h2 id="it-wasnt-email-enumeration">It wasn't email enumeration</h2><p>That question was nagging at me, so I dug deeper. The call in MAG's code looks up one person at a time:</p><pre><code>GET /api/users/{email}
</code></pre><p></p><p>So how do you get to 8.8 million records? You'd need the email addresses first, and you can't guess them all. I know I can't be the only person using service-specific aliases like I do.</p><p>Turns out it's easy, you don't enumerate. You export!</p><pre><code>GET /api/export/data.csv?dataTypeName=user&range=All
</code></pre><p></p><p><code>dataTypeName</code> is the only required parameter. One request, the entire user table. There's a JSON variant, and a list-walking route via <code>/api/lists</code> into <code>/api/lists/getUsers</code>, which returns every address on a list without pagination.</p><p><code>dataTypeName</code> accepts 51 values. Among them:</p><p></p><table>
<thead>
<tr>
<th>Value</th>
<th>What it returns</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>user</code></td>
<td>every customer profile</td>
</tr>
<tr>
<td><code>purchase</code></td>
<td>transaction history</td>
</tr>
<tr>
<td><code>customEvent</code></td>
<td>whatever MAG defined — bookings, parking</td>
</tr>
<tr>
<td><code>emailSend</code></td>
<td>every message sent to you</td>
</tr>
<tr>
<td><code>emailOpen</code> / <code>emailClick</code></td>
<td><strong>IP address and user agent, per open</strong></td>
</tr>
</tbody>
</table>
<p></p><p>That last row explains something that puzzled me in the <a href="https://haveibeenpwned.com/Breach/ManchesterAirportsGroup?utm_source=scotthelme.co.uk" rel="noreferrer">Have I Been Pwned entry for this breach</a>: alongside names and vehicle registrations, the compromised data classes include IP addresses and browser user agent strings. Those didn't come from the website. They came from tracking pixels in marketing emails, exported by data type.</p><p></p><h2 id="and-it-could-write">And it could write</h2><p>The same global auth covers the destructive half of the API too:</p><pre><code>DELETE /api/users/{email} delete a customer
DELETE /api/lists/{listId} delete a list
POST /api/users/forget GDPR erasure
POST /api/users/bulkUpdate mass-rewrite profiles
POST /api/users/updateEmail change someone's address
</code></pre><p></p><p>There's no indication FulcrumSec did any of this, and I'm glad, but they could have just nuked everything from orbit and MAG would have been really screwed. For four whole years, the capability to delete Manchester Airports Group's database was a <code>view-source</code> away. This wasn't only a confidentiality exposure. It was a colossal integrity and availability exposure too, and MAG just got lucky. How do they now trust any of the data that remains in the database? </p><p></p><h2 id="how-can-i-be-sure">How can I be sure?</h2><p>Because looking at the published data, my own records gave it away. Buried in a few hundred fields of parking bookings and Fast Track purchases were these:</p><pre><code class="language-json">"itblInternal.regionCode": "GB",
"itblInternal.isAnonymousUser": false,
"itblInternal.isUnknownUser": false,
"itblInternal.phoneType": "MOBILE",
"itblInternal.phoneCountry": "GB",
"itblInternal.emailDomain": "scotthelme.co.uk",
"itblDS.brandAffinityLabel": "negative",
"signupSource": "UpdateSubscriptionsAPI"
</code></pre><p></p><p><code>itbl</code> is Iterable's own system namespace. Every one of those values is something Iterable derives rather than something a customer supplies, my phone number parsed into a type and a country, the domain split off my address, a region code inferred. <code>itblDS</code> is Iterable Data Science, and Brand Affinity is a machine-learning score their platform generates. Apparently mine is negative, which feels fair.</p><p>Nothing in a car park booking produces a machine-learning affinity score. That field was written by Iterable, which means this record passed through Iterable. Whatever was taken carried Iterable's derived fields, Iterable's list and channel IDs, and Iterable's export flattening. And the group who took it said they used Iterable credentials from client-side JavaScript, which is where three of them had been sitting for four years, and which MAG revoked in the days before disclosing.</p><p><code>signupSource</code> names the API method that created the record: <code>POST /api/users/updateSubscriptions</code>.</p><p>The record also carries Iterable's own subscription state, <code>emailListIds</code>, <code>unsubscribedChannelIds</code>, <code>subscribedMessageTypeIds</code>, which appear in exactly two places: the response from <code>GET /api/users/{email}</code>, and the output of the user export.</p><p>Even the shape is a tell. Every nested value appears twice, once as an object and once flattened into a dotted key:</p><pre><code class="language-json">"cip.lastBooking.parking.vehicleReg": "HE23LME",
"cip.lastBooking.parking": { "vehicleReg": "HE23LME", ... }
</code></pre><p></p><p>That's what Iterable's export produces when it flattens <code>dataFields</code> into columns while keeping the original object. So the data left Iterable. Now, which type of key?</p><p>Iterable publish their full OpenAPI specification, unauthenticated, at <code>https://api.iterable.com/api-docs</code>. The <code>security</code> block is global, every endpoint takes the same <code>Api-Key</code> header. But each endpoint is also tagged with the key types permitted to call it, in an extension called <code>x-iterable-api-key-types</code>:</p><pre><code class="language-text">POST /api/events/track Server-side, JavaScript, Mobile, Web
POST /api/users/update Server-side, JavaScript, Mobile, Web
GET /api/users/{email} Server-side
GET /api/export/data.csv Server-side
GET /api/lists/getUsers Server-side
</code></pre><p></p><p>Of 131 endpoints, 98 are server-side only. Twelve accept a browser key.</p><p>At first, I thought the code proves the key class: <code>GET /api/users/{email}</code> is server-side only, MAG's page called it, therefore server-side key, right? But look at that call again, it ends in <code>.catch(e => { console.warn(e) })</code>, and the <code>IterableUID</code> it writes is read by nothing. With a browser-scoped key the event tracking would still have worked, the profile lookup would have 401'd into a swallowed promise, and the site would have continued. It could have been failing silently since 2022 and nobody would have known.</p><p>The export is what settles it. Producing that record requires the user export or the list endpoints, and both are server-side only. So a server-side key was used. The only credentials in play were three server-side keys published in MAG's own JavaScript, the ones FulcrumSec named as their route in, and the ones MAG revoked, all three, in the days before going public.</p><p>Iterable's fingerprints are on the data, and Iterable's spec says what key type was required to access that data. I'm confident that, on balance, this is most likely how the data was stolen.</p><p></p><h2 id="so-what-was-this-api-key-actually-used-for">So what was this API key actually used for?</h2><p>I went looking for the code that needed this key, expecting something substantial. It's 1,119 bytes, lazy-loaded from <code>/_next/static/chunks/95707.a17f18ff1a88c71f.js</code>:</p><pre><code class="language-js">useEffect(() => {
const email = new URLSearchParams(location.search).get("email");
const userId = sessionStorage.getItem("IterableUID")
|| new URLSearchParams(location.search).get("userId");
const payload = {
eventName: props.eventName || "pageView",
createdAt: Date.now(),
dataFields: { pageUrl: props.pageUrl || location.href.split("?")[0] }
};
if (email) payload.email = email;
if (userId) payload.userId = userId;
if (props.templateId) payload.templateId = props.templateId;
if (props.campaignId) payload.campaignId = props.campaignId;
if (email || userId) trackEvent(payload); // POST /api/events/track
if (email) lookupUser(email); // GET /api/users/{email}
}, []);</code></pre><p></p><p>That's it. That's the whole reason. </p><p>Iterable's marketing emails link back to the site with the recipient's address sitting in the query string, <code>?email=you@example.com&userId=...</code>. This component reads it, fires a <code>pageView</code> event back to Iterable, and the campaign gets credit for your visit. Attribution. That's the job. </p><p>And the second call, the dangerous one, <code>GET /api/users/{email}</code>, the arbitrary profile read? It writes the result into <code>sessionStorage</code> under <code>IterableUID</code>, and the same component reads it back on later page views in the session, so subsequent visits still carry an identifier.</p><p>So a credential with access to all but ten of 131 endpoints, including a full database export and the ability to delete customers, was published to every visitor of three airport websites for over four years, so that a marketing email could get attribution credit for a page view. And half of what it did was populate a cache that nothing reads.</p><p></p><h2 id="this-is-not-a-mag-problem">This is not a MAG problem</h2><p>It would be easy to file this under "airport group made silly mistake", but it isn't that.</p><p>The pattern is: a marketing or analytics platform gives you an API key, the integration guide shows you a <code>fetch</code> call, and it works. It works in dev, it works in staging, it works in prod. Nothing fails. No error appears. No test goes red. The application behaves perfectly while a credential with server-side authority sits in a file that every visitor downloads.</p><p>And look at what the browser was actually doing with it. I searched every chunk the site loads, and every archived bundle back to 2022, for any sign of the valuable data being sent from the page including vehicle registrations, booking references, travel dates. Nothing. Not one field, ever. The only thing the browser ever wrote to Iterable was this:</p><pre><code class="language-js">{ email, eventName, dataFields: { phoneNumber, airportCode } }
</code></pre><p></p><p>A newsletter signup. Your email, your phone number, and which airport you're a customer of.</p><p>Everything that made this breach worth 640GB, the parking history, the registrations, and the forward travel, was put into Iterable by a server-side sync from MAG's booking platform. That integration was fine. It was never exposed.</p><p>So the cause of this whole issue is clear, and it's worse than "they leaked a key": email click tracking was given a credential scoped to the entire customer database,<strong> in order to record a click.</strong> The read authority and the write requirement were four orders of magnitude apart, and nothing in the toolchain notices a mismatch like that.</p><p>And there is no natural moment at which anyone notices. Secret scanning runs against your repository, and this key probably <em>was</em> in a config file or a CI variable, which is exactly where it's supposed to be. The leak happens at build time, when it gets baked into a bundle. A pen test scopes your login and your booking flow, not the twelfth webpack chunk. A WAF sees nothing, because there's no malicious request to your origin; the traffic goes straight from the victim's browser to <code>api.iterable.com</code> and never touches your infrastructure at all.</p><p>Which is the part I find most uncomfortable. This vector never touched MAG's servers. None of it would appear in a log MAG owns. The entire incident happened in other people's browsers and on somebody else's API, using a credential they published themselves.</p><p>The uncomfortable detail is that this one was avoidable by reading the manual. Iterable's documentation carries it in a box marked WARNING:</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/360043464871-API-Keys.png" class="kg-image" alt="No Hacking Required: The Manchester Airports Group Data Breach" loading="lazy" width="706" height="179" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/09/360043464871-API-Keys.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/360043464871-API-Keys.png 706w"><figcaption><a href="https://support.iterable.com/hc/en-us/articles/360043464871-API-Keys?utm_source=scotthelme.co.uk"><span style="white-space: pre-wrap;">source</span></a></figcaption></figure><p></p><blockquote>"Never embed a server-side API key in client-side code (whether JavaScript, a mobile application, or otherwise), since they can be used to access project data. Use these server-side API keys only when making API calls from your servers."</blockquote><p></p><p>They issue client-side key types that reach only the handful of endpoints front-end code actually needs, and JWT authentication is on by default when you create one. It's been mandatory for new organisations since 30 August 2022.</p><p>The keys in these bundles landed in June and July 2022. They missed that cutoff by weeks, and then nobody looked again for four years.</p><p>I've spent years arguing that the browser is a part of your attack surface that most organisations just don't monitor. This is that argument with 8.8 million records attached to it.</p><p></p><h2 id="what-to-actually-do-about-it">What to actually do about it</h2><p>There are a few important steps you can take right now, and considerations you take forwards:</p><p></p><ol><li><strong>Grep your own bundles today.</strong> Not your repo, your built, deployed JavaScript. Pull every script your production pages load and search for <code>api_key</code>, <code>apiKey</code>, <code>Api-Key</code>, <code>token</code>, <code>secret</code>, and 32-character hex strings. This takes an afternoon and it is the highest-value hour of work in this entire post.</li><li><strong>Assume anything ever shipped to a browser is public forever.</strong> Rotating the key fixes the future. It does not remove it from the Internet Archive, from someone's <code>curl</code> history, or from a threat actor's notes. Every one of these three keys is still sitting in a public archive right now.</li><li><strong>Move the call server-side.</strong> If your browser needs data from Iterable, Klaviyo, Braze or anything like them, it should ask <em>your</em> backend, which holds the credential and enforces who can ask for what. That's a small proxy endpoint, not an architecture project.</li><li><strong>Use the key type the vendor built for browsers.</strong> Iterable issue five, and the client-side ones reach a handful of endpoints instead of all of them. A leaked browser key can act as one person. A leaked server key can export everyone. Same header, same integration effort, completely different blast radius.</li><li><strong>Monitor what your pages actually load and where they send data.</strong> You cannot review ninety content-hashed chunks by hand every deploy. Something has to watch the scripts on your pages and the destinations they talk to, and tell you when either changes.</li></ol><p></p><p>That last one is the reason I do what I do, so yes I'm a little biased, but it is <strong>exactly</strong> what <a href="https://report-uri.com/?utm_source=scotthelme.co.uk" rel="noreferrer">Report URI</a> was built to do.</p><p></p><h2 id="the-uncomfortable-truth">The uncomfortable truth</h2><p>While confirming the Iterable key is gone, I looked at the rest of the same configuration object that still ships to browsers today.</p><p>It is not empty. There are several other credential-shaped values in it, including one paired with an AWS API Gateway endpoint.</p><p>I'm not publishing those, but they're as public as the others, and I'm not sure a couple of those should be public. But it does illustrate the point better than anything else I could write: the Iterable key was removed because an attacker forced the issue and 8.8 million people paid for it. The ones sitting next to it are still there, so, has anybody looked?</p><p></p><h2 id="want-the-people-who-did-this-on-your-team">Want the people who did this on your team?</h2><p>Everything in this post came from reading JavaScript that three different airports published to the world. No exploit, no access, no privileged information. A key sitting in the seventeenth of ninety chunk files, and four years of archived copies to date it against.</p><p>That's the uncomfortable bit, this was discoverable the entire time. It was discoverable in 2022, and in 2023, and on every single deploy until August 2026. Nobody looked, because looking means reading ninety content-hashed bundles after every release, and nobody really does that.</p><p>So by all means, go and grep your bundles. You should. But understand what a grep gives you: a snapshot of today. Run it in May 2022 against Manchester Airport and you'd have found nothing at all, the key landed in July 2022. The exposure isn't a state you can check once. It's a thing that arrives on a Tuesday in a chunk nobody reviewed, and then stays for four years.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?utm_source=scotthelme.co.uk"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/report-uri-logo.png" class="kg-image" alt="No Hacking Required: The Manchester Airports Group Data Breach" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/09/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><p>That's the problem <a href="https://report-uri.com/?utm_source=scotthelme.co.uk" rel="noreferrer">Report URI</a> was built for. Not reporting on it after someone else tells you, watching the scripts your pages actually load, and the destinations they actually send data to, and telling you the moment either one changes. If MAG had been watching, the question wouldn't have been "did anyone take the key". It would have been "why is our booking site talking to <code>api.iterable.com</code> from the browser?", and it would have been asked in July 2022, not by an extortion group in August 2026.</p><p>Your website is running code right now. Do you know what code it's running?</p><p><a href="https://report-uri.com/register?plan=business2025&ref=scotthelme.co.uk"><strong>Start your free trial, no credit card required →</strong></a></p><p></p>]]></content:encoded>
</item>
<item>
<title><![CDATA[The ultimate road trip combo: Starlink Mini + UniFi Travel Router]]></title>
<description><![CDATA[<p>I recently went on an epic road trip around Europe, covering 1,645 miles (2,647 km), and we took in some amazing sights and locations. As a tech geek, I was worried about my connectivity on the trip, so before we set off, I came up with a plan!</p>]]></description>
<link>https://scotthelme.co.uk/the-ultimate-road-trip-combo-starlink-mini-unifi-travel-router/</link>
<guid isPermaLink="false">6a8c17b0f10eb90001ef02a9</guid>
<category><![CDATA[Ubiquiti]]></category>
<category><![CDATA[UniFi]]></category>
<category><![CDATA[Travel Router]]></category>
<category><![CDATA[Starlink]]></category>
<dc:creator><![CDATA[Scott Helme]]></dc:creator>
<pubDate>Mon, 24 Aug 2026 13:50:51 GMT</pubDate>
<media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/porsche-road-trip.png" medium="image"/>
<content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/porsche-road-trip.png" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router"><p>I recently went on an epic road trip around Europe, covering 1,645 miles (2,647 km), and we took in some amazing sights and locations. As a tech geek, I was worried about my connectivity on the trip, so before we set off, I came up with a plan!</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/08/image-2.png" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="768" height="1038" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/image-2.png 768w" sizes="(min-width: 720px) 720px"></figure><p></p><h2 id="did-we-really-need-this">Did we really need this?</h2><p>On a more serious note, we all like to have connectivity, but I am also responsible for running my own company, <a href="https://report-uri.com/?utm_source=scotthelme.co.uk" rel="noreferrer">Report URI</a>, which has other staff members. I wanted to make sure that I had at least somewhat reliable connectivity whilst I was away as, anyone running their own company can confirm, you're never really 'not working' when everything falls to you.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?utm_source=scotthelme.co.uk"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/report-uri-logo-1.png" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><p>Of course we could get an eSIM for my phone, and then I could tether, but my wife would also need connectivity, so that's another eSIM, and then on the longer stints in the car it'd be nice to give my son some connectivity too... It was quickly looking like it wouldn't be practical, and there were also concerns about the times we wouldn't have any cellular connectivity too.</p><p></p><h2 id="the-two-devices-we-bought-for-the-trip">The two devices we bought for the trip</h2><p>Being quite the fan of <a href="https://scotthelme.co.uk/tag/ubiquiti/?utm_source=scotthelme.co.uk" rel="noreferrer">Ubiquiti kit</a>, I wanted to try out the <a href="https://uk.store.ui.com/uk/en/products/utr?utm_source=scotthelme.co.uk" rel="noreferrer">Unifi Travel Router</a>. This is a tiny device that can connect to an external Wi-Fi network and then broadcast your usual home Wi-Fi networks for all of your devices to connect to. Super handy because all of my devices then just connect to my home network without any configuring or joining the hotel Wi-Fi, and, the UTR can VPN me home so all of my usual devices and services are available if I want them.</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/08/Screenshot-2026-08-24-111730.png" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="469" height="587"></figure><p></p><p>The other bit of kit I purchased was a Starlink Mini. I got mine from a <a href="https://www.currys.co.uk/products/starlink-mini-satellite-antenna-and-wifi-router-kit-dualband-10269577.html?utm_source=scotthelme.co.uk" rel="noreferrer">local electronics retailer</a> that had a deal on, and I'm sure you can find a suitable retailer in your area. I also bought a carry case from Amazon along with some accessories to mount it to the panoramic glass roof in the Porsche, and a 12V 'cigarette' socket adapter that could provide the higher power the Starlink needs from the car. Note: the USB-C ports in your car might not provide enough power for the Starlink, mine didn't, hence the 12V adapter.</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/08/IMG_9898.jpeg" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="1645" height="2193" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/IMG_9898.jpeg 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/IMG_9898.jpeg 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/08/IMG_9898.jpeg 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/IMG_9898.jpeg 1645w" 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/08/IMG_9897.jpeg" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="2000" height="2667" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/IMG_9897.jpeg 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/IMG_9897.jpeg 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/08/IMG_9897.jpeg 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/08/IMG_9897.jpeg 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/08/IMG_9472.jpeg" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="2000" height="2667" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/IMG_9472.jpeg 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/IMG_9472.jpeg 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/08/IMG_9472.jpeg 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/08/IMG_9472.jpeg 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>It's quite a nice setup, out of the way, and it automatically powers up when the car turns on with no effort from us. There's also a nice feature of being able to leave the 12V power source in the boot (trunk, for the Americans) turned on for a period of time after you leave the car and lock it, so we can keep connectivity in the general vicinity before it powers down. </p><p></p><h2 id="how-well-did-the-unifi-travel-router-work">How well did the UniFi Travel Router work?</h2><p>We used the UTR on our first day of the trip as we drove a few hundred miles to the south coast of England to take the ferry to Spain on the Portsmouth to Santander route. The Wi-Fi on the ferry is not free, and I didn't fancy paying for a whole bunch of devices either. Instead, I could buy a Wi-Fi pass, join the UTR to the ferry Wi-Fi and then the UTR will broadcast our usual Wi-Fi network for all of our devices! I had Wi-Fi on my phone, my laptop, my wife had it on her devices and my son had it on his. This saving alone paid for ~50% of the cost of the UTR itself! We didn't have a balcony or any external area we could place the Starlink, and the window in our room was tiny, but the UTR worked out great for the two nights on the ferry. It was a similar story in every hotel and Airbnb we stayed in for the whole trip. Rather than arrive at a hotel and have to go through the Wi-Fi joining process on a whole bunch of devices, I would just power up the UTR, join it to the Wi-Fi, and all of our devices would automatically connect to our normal home Wi-Fi. This was a very nice convenience feature.</p><p>One unexpected benefit of the UTR came in a hotel with poor Wi-Fi signal. At the end of the room with the seats and desk, there was a single bar of signal, nothing worked and it dropped regularly. By the door to the hallway, signal was great, but you can't sit there with your laptop and do emails! I took a phone charger to power the USB-C UTR, plugged it in by the door and it had a good signal. It then broadcast our usual Wi-Fi network into the room and everyone had great Wi-Fi, problem solved!</p><p>Overall, I'd say the UTR is definitely worth it. I didn't use the VPN feature much, it wasn't what I needed or wanted from it, but I did test it out and it worked perfectly. The convenience of joining a single device to an external Wi-Fi network and then having all of your devices automatically connect is really nice, and the portability of it allows you to move it around for the best signal strength. If you're having to pay for Wi-Fi somewhere, it's a no-brainer. (The UTR can also do Ethernet backhaul, but I never needed to try that out)</p><p></p><h2 id="how-well-did-the-starlink-mini-work">How well did the Starlink Mini work?</h2><p>We set everything up with the Starlink Mini before we left home so as soon as we hit the road, we were on the built-in Wi-Fi network on all of our devices. In many ways, that's all I have to say about the Starlink... We plugged it in, set it up, mounted it, and that's it. It just worked. Every single day, every single time. The damn thing is annoyingly flawless.</p><p>I'm not one for hype or fanfare, but I feel like Starlink will be a transformational technology. It doesn't matter where you are, as long as you can see the sky, you're connected. On some of the mountain roads and coastal roads we took on the trip, we had no phone service at all. Genuinely zero. We were still fully connected thanks to the Starlink. Even with the multiple speed tests we did at various locations, we were always seeing 100Mbps+, and we even did some high speed tests at ̶2̶0̶0̶k̶m̶/h̶ 140km/h. It turns out how fast you're moving is kind of irrelevant because the satellite you're connected to is going at 27,600 km/h, so you're basically not moving as far as it's concerned.</p><p>The only thing I had to make a change for was that I normally do wireless Apple CarPlay, but I had to switch to wired Apple CarPlay so my phone could connect to the Starlink Wi-Fi network for data instead of the Porsche Wi-Fi for CarPlay. That's it. It was awesome to be able to have stable voice calls, stable video calls, large attachments are no issue, you just use your devices like normal without any consideration for data or connectivity.</p><p></p><h2 id="theyre-the-ultimate-combo">They're the ultimate combo</h2><p>You might not be planning your own road trip, and I can definitely see how each of these devices has its own benefits that would justify buying either of them in isolation. The UTR isn't much bigger than a credit card and ~1cm thick, but provides huge value and benefits. The Starlink is a different beast in the physical dimensions, but it's now going to form part of our regular travel kit. Since the European road trip, we've already used it on two more trips including a long weekend in a remote Scottish location, and during a race weekend in the paddock when we're uploading and downloading huge video and telemetry files and the Wi-Fi at the race track just can't provide reliable or high-speed connectivity.</p><p>Hopefully that gives a nice summary of the benefits of these two devices, but if you want it in a single sentence for each:</p><p>The Starlink will give you connectivity where you otherwise have none.</p><p>The UTR will make whatever connectivity you have behave like you're at home.</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/08/IMG_9799.jpeg" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="2000" height="2667" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/IMG_9799.jpeg 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/IMG_9799.jpeg 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/08/IMG_9799.jpeg 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/08/IMG_9799.jpeg 2400w" sizes="(min-width: 720px) 720px"></figure>]]></content:encoded>
</item>
<item>
<title><![CDATA[Introducing dbsc.dev: Does Your Browser Support DBSC?]]></title>
<description><![CDATA[<p>Every other web platform feature I've ever written about, I've been able to test in some easy way. Open DevTools, type the name of the thing, see if it's there. Device Bound Session Credentials doesn't work like that: there is no way</p>]]></description>
<link>https://scotthelme.co.uk/introducing-dbsc-dev-does-your-browser-support-dbsc/</link>
<guid isPermaLink="false">6a87306bf001a000018b8f0a</guid>
<category><![CDATA[DBSC]]></category>
<dc:creator><![CDATA[Scott Helme]]></dc:creator>
<pubDate>Fri, 21 Aug 2026 13:42:10 GMT</pubDate>
<media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-dev.png" medium="image"/>
<content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-dev.png" alt="Introducing dbsc.dev: Does Your Browser Support DBSC?"><p>Every other web platform feature I've ever written about, I've been able to test in some easy way. Open DevTools, type the name of the thing, see if it's there. Device Bound Session Credentials doesn't work like that: there is no way for a page to ask the browser whether it supports DBSC. None! So I built <a href="https://dbsc.dev/?utm_source=scotthelme.co.uk">dbsc.dev</a>, which answers the question the only way I could think to answer it.</p><p></p><figure class="kg-card kg-image-card"><a href="https://dbsc.dev/?utm_source=scotthelme.co.uk"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/image-1.png" class="kg-image" alt="Introducing dbsc.dev: Does Your Browser Support DBSC?" loading="lazy" width="1428" height="898" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/image-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/image-1.png 1428w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="a-feature-you-cannot-feature-detect">A Feature You Cannot Feature-Detect</h3><p>Here's the thing that makes DBSC unusual. Almost everything we bolt onto the web platform comes with a hook you can poke at from JavaScript.</p><pre><code class="language-js">if (window.PublicKeyCredential) { /* passkeys are available */ }
if ('serviceWorker' in navigator) { /* ... */ }
</code></pre><p></p><p>DBSC has nothing. No <code>navigator.deviceBoundSessions</code>, no constructor, no promise that rejects. The entire protocol lives in HTTP headers, and it's driven by the server, not the page. Your server says "I'd like to bind this session to a device key" and the browser either quietly gets on with it, or it quietly ignores you.</p><p>That design is deliberate, and it's a good thing. It's what makes DBSC safe to deploy: a browser that doesn't support it ignores the registration header and carries on with normal cookie auth, so you cannot lock anyone out by switching it on. I've made that point before and I stand by it. But it does leave you with an awkward question if you're on the other side of it, wondering whether your own browser is doing any of this.</p><p>You can't ask. You can only find out by trying...</p><p></p><h3 id="watching-for-the-answer">Watching For The Answer</h3><p>So that's what the site does. When you load <a href="https://dbsc.dev/?utm_source=scotthelme.co.uk" rel="noreferrer">dbsc.dev</a>, the response carries a registration header, exactly as a real deployment would:</p><pre><code class="language-http">HTTP/2 200
Cache-Control: no-store
Secure-Session-Registration: (ES256); path="/dbsc/register"; challenge="519aaae6ee95..."
</code></pre><p></p><p>If your browser supports DBSC, it generates a key pair, signs that challenge with the private half, and POSTs the result back to <code>/dbsc/register</code> without any involvement from the page at all. On my machine that round trip takes about 700ms. Meanwhile, the page is polling a status endpoint, waiting to see whether the registration lands.</p><p>If it lands, you get a green box. If nothing arrives within eight seconds, you get a red one — and I've been careful about what that red box says. It says <em>no registration attempt arrived in eight seconds</em>. It does not say your browser can't do DBSC, because that's not something this test can prove. Maybe an extension stripped the header. Maybe an enterprise policy turned it off. Maybe you're on a platform with no key store to bind to. The site lists all of that rather than claiming certainty it doesn't have.</p><p></p><h3 id="the-bit-i-actually-wanted-to-build">The Bit I Actually Wanted To Build</h3><p>A yes/no answer is fine, but it isn't very interesting, and honestly you can usually guess. What I wanted was to see the protocol in action and make it useful for debugging.</p><p>So once your browser registers, the page shows you the whole exchange. The registration JWT your browser sent, decoded. The device public key it generated, as a JWK, with its thumbprint and its PEM. The session instructions the server sent back. A plain <code>fetch()</code> proving the bound cookie is riding your ordinary requests, which is the entire security property in one line.</p><p>One detail I enjoyed: the device key doesn't travel where you might expect. The payload of that registration JWT is almost empty.</p><pre><code class="language-json">{
"jti": "mvt-nE8miIcX--li4LlmEo0bcs5m3BhqJoS2aob76Pc"
}
</code></pre><p></p><p>Just the challenge, signed. The key itself is up in the JWT <em>header</em>, as a <code>jwk</code> alongside <code>"typ": "dbsc+jwt"</code>. I had it wrong in my own code until I decoded a real one from Chrome.</p><p>While I'm being precise about what the site can and can't tell you: it cannot tell you your key is in a TPM. DBSC carries no attestation, so a server sees a public key and a valid signature and that's it. Anyone claiming otherwise from server-side data alone is guessing.</p><p></p><h3 id="watching-a-refresh-happen-live">Watching A Refresh Happen Live</h3><p>The refresh is the part nobody ever sees, because in production it happens to a cookie you were never looking at. It's also the part that makes DBSC work: the bound cookie is short-lived, and renewing it needs a fresh signature from the device key. Both the cookie and the challenge have to change every single time.</p><p>I wanted people to be able to watch that, so I set the bound cookie's lifetime to three minutes. Keep the tab open and you can see your device re-sign, with the old and new cookie values shown side by side. I couldn't go shorter than three minutes as I kept hitting TPM signing quota limits, so apologies for the short wait.</p><p></p><h3 id="why-you-might-actually-use-it">Why You Might Actually Use It</h3><p>If you're curious, it answers the question of whether or not your browser supports DBSC in just a few seconds, and then shows you the protocol if you'd like to know more.</p><p>If you're implementing DBSC, it's a reference exchange you can point at. Here's what a real registration JWT looks like. Here's the two-phase refresh — the 403 with the challenge, then the signed retry. Here's what rotation looks like when it's working. I wrote the server side of this twice before I got the wire protocol right, and a working example would have saved me a fortnight.</p><p>If you're filing a bug, there's a copy button that gives you the whole diagnostic as text: the verdict, the decoded JWTs, the key, the timeline, and what your browser told us about itself. Rather more useful in an issue than "DBSC doesn't work for me".</p><p></p><h3 id="on-privacy">On Privacy</h3><p>Nothing is retained. Each visit mints a throwaway identifier, and the key material and timeline are deleted within the hour. No IP logging, no analytics on your session, nothing shared. The key you see on the page is a public key your own browser generated for that one page load, and it's worthless to me.</p><p>I wanted to be clear on this, given the site's entire subject is device-bound cryptography.</p><p></p><h3 id="under-the-hood">Under The Hood</h3><p>The server side is <a href="https://github.com/report-uri/dbsc-php?utm_source=scotthelme.co.uk">dbsc-php</a>, the open source library I extracted from Report URI's production DBSC integration. The site is a couple of hundred lines of PHP on top of it. If you want to run DBSC on your own site and you're in the PHP world, that library carries all the wire-protocol corrections that only surface when you integrate against a real browser — including several <a href="https://scotthelme.co.uk/everything-i-learned-shipping-device-bound-session-credentials/?utm_source=scotthelme.co.uk" rel="noreferrer">I learned about the hard way</a> and wrote up separately.</p><p>The site is sponsored by <a href="https://report-uri.com/?utm_source=scotthelme.co.uk">Report URI</a>, same as <a href="https://whynopasskeys.com/?utm_source=scotthelme.co.uk">Why No Passkeys?</a>.</p><p></p><h3 id="client-support">Client Support</h3><p>Chrome remains the only browser that implements DBSC. It's generally available on Windows, and <a href="https://scotthelme.co.uk/device-bound-session-credentials-lands-in-chrome-on-macos/?utm_source=scotthelme.co.uk" rel="noreferrer">support has since landed on macO</a>S. Firefox and Safari have nothing. That'll date quickly, which is rather the point of building a site that tests instead of a table that claims.</p><p>Go and find out: <a href="https://dbsc.dev/?utm_source=scotthelme.co.uk"><strong>dbsc.dev</strong></a></p><p></p><h3 id="sources">Sources</h3><ul><li><a href="https://w3c.github.io/webappsec-dbsc/?utm_source=scotthelme.co.uk">Device Bound Session Credentials</a> — W3C specification</li><li><a href="https://developer.chrome.com/docs/web-platform/device-bound-session-credentials?utm_source=scotthelme.co.uk">Device Bound Session Credentials</a> — Chrome for Developers</li><li><a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?utm_source=scotthelme.co.uk">Device Bound Session Credentials: making stolen cookies useless</a> — my earlier explainer</li><li><a href="https://github.com/report-uri/dbsc-php?utm_source=scotthelme.co.uk">report-uri/dbsc-php</a> — the server library</li><li><a href="https://scotthelme.co.uk/everything-i-learned-shipping-device-bound-session-credentials/?utm_source=scotthelme.co.uk" rel="noreferrer">Everything I Learned Shipping Device Bound Session Credentials</a> - my stories from the trenches</li></ul>]]></content:encoded>
</item>
<item>
<title><![CDATA[Device Bound Session Credentials lands in Chrome on macOS]]></title>
<description><![CDATA[<p><a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?utm_source=scotthelme.co.uk" rel="noreferrer">Device Bound Session Credentials</a> (DBSC) is Chrome's answer to session cookie theft, usually by InfoStealer malware. Instead of a cookie being a bearer token that works anywhere it's pasted, DBSC binds the session to a private key that lives in your device's hardware and</p>]]></description>
<link>https://scotthelme.co.uk/device-bound-session-credentials-lands-in-chrome-on-macos/</link>
<guid isPermaLink="false">6a7afcfd9f063f00018dac2d</guid>
<category><![CDATA[DBSC]]></category>
<category><![CDATA[macOS]]></category>
<category><![CDATA[chrome]]></category>
<dc:creator><![CDATA[Scott Helme]]></dc:creator>
<pubDate>Tue, 11 Aug 2026 14:25:04 GMT</pubDate>
<media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-macos.png" medium="image"/>
<content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-macos.png" alt="Device Bound Session Credentials lands in Chrome on macOS"><p><a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?utm_source=scotthelme.co.uk" rel="noreferrer">Device Bound Session Credentials</a> (DBSC) is Chrome's answer to session cookie theft, usually by InfoStealer malware. Instead of a cookie being a bearer token that works anywhere it's pasted, DBSC binds the session to a private key that lives in your device's hardware and can never be exported. Chrome has to prove possession of that key to be issued fresh cookies, so a stolen cookie stops working almost immediately after it leaves the device.</p><p>That protection shipped to Windows first, backed by the TPM, and it's now on macOS, backed by the Secure Enclave.</p><p></p><h2 id="the-timeline">The timeline</h2><p>The Chrome Enterprise and Education release notes are the clearest official statement of platform availability:</p><blockquote><strong>Chrome 145 on Windows</strong>: Feature rolls out gradually.<br><strong>Chrome 147 on macOS</strong>: Feature rolls out gradually.</blockquote><p></p><p>Note "rolls out gradually" on both. This isn't a switch that flips for everyone the moment you update. It's a staged rollout controlled by Chrome's variations system (called <a href="https://developer.chrome.com/docs/web-platform/chrome-finch?utm_source=scotthelme.co.uk" rel="noreferrer">Finch</a>) so your browser may or may not have support for it just yet.</p><p>You can see that split in the Chromium source. In <code>net/base/features.cc</code>:</p><pre><code class="language-cpp">#if BUILDFLAG(IS_WIN)
BASE_FEATURE(kDeviceBoundSessions, base::FEATURE_ENABLED_BY_DEFAULT);
#else
BASE_FEATURE(kDeviceBoundSessions, base::FEATURE_DISABLED_BY_DEFAULT);
#endif
</code></pre><p>Windows gets DBSC by default. Every other platform, macOS included, is off by default and depends entirely on the variations seed to switch it on. If you're testing DBSC on a Mac and nothing happens like when I tested it, this is why.</p><p></p><h2 id="reading-your-variations-assignment">Reading your variations assignment</h2><p><code>chrome://version</code> lists your active variations, but as hashed pairs:</p><pre><code>4e5d86a8-5e85fe73
</code></pre><p></p><p>Those are the trial name and group name, each hashed. The algorithm is <code>base::HashFieldTrialName</code> in <code>base/metrics/metrics_hashes.cc</code>:</p><pre><code class="language-cpp">uint32_t HashFieldTrialName(std::string_view name) {
std::array<uint8_t, SHA_DIGEST_LENGTH> hash;
::SHA1(reinterpret_cast<const uint8_t*>(name.data()), name.size(), hash.data());
return U32FromLittleEndian(base::span(hash).first<4>());
}
</code></pre><p></p><p>SHA-1 of the name, take the first four bytes, read them as a <strong>little-endian</strong> uint32. The pair is then printed <code>%x-%x</code>, lowercase, no zero padding. Here's the Python to reproduce it:</p><pre><code class="language-python">import hashlib, struct
def hash_name(name):
digest = hashlib.sha1(name.encode()).digest()[:4]
return struct.unpack('<I', digest)[0]
trial = "DeviceBoundSessionCredentialsMac"
group = "Control_Post149Split_30pct"
print("%x-%x" % (hash_name(trial), hash_name(group)))
# 4e5d86a8-5e85fe73
</code></pre><p></p><p>Some useful values to search your own list for:</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Name</th>
<th>Hash</th>
</tr>
</thead>
<tbody>
<tr>
<td>Trial: <code>DeviceBoundSessionCredentialsMac</code></td>
<td><code>4e5d86a8</code></td>
</tr>
<tr>
<td>Group: <code>Enabled</code></td>
<td><code>3f4a17df</code></td>
</tr>
<tr>
<td>Group: <code>Disabled</code></td>
<td><code>3d47f4f4</code></td>
</tr>
<tr>
<td>Group: <code>Default</code></td>
<td><code>ca7d8d80</code></td>
</tr>
<tr>
<td>Group: <code>Control</code></td>
<td><code>f23d1dea</code></td>
</tr>
<tr>
<td>Group: <code>ClientSideFeatureConflict</code></td>
<td><code>bfe70100</code></td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<p></p><p>The hash is one-way, so you can only confirm names you can guess, or you can just use this URL and view them as readable text:</p><pre><code>chrome://version/?show-variations-cmd
</code></pre><p></p><p>That prints the full variations command line with readable trial and group names, in the form <code>TrialName/GroupName</code> so you can search for <code>DeviceBoundSessionCredentialsMac</code>.</p><p></p><h2 id="what-a-control-group-looks-like">What a control group looks like</h2><p>This is where it got interesting on my Mac. My assignment:</p><pre><code>DeviceBoundSessionCredentialsMac/Control_Post149Split_30pct
</code></pre><p></p><p>A control arm of a 30% split. Control groups exist so Google can measure the treatment against a baseline, and this one isn't passive. Scanning <code>--disable-features</code> in the same output, every one of these is tagged <code><DeviceBoundSessionCredentialsMac</code>, meaning the study is what switches them off:</p><pre><code>UseUnexportableKeyServiceInBrowserProcess
PersistDeviceBoundSessions
UnexportableKeyDeletion
DeviceBoundSessionsFederatedRegistration
DeviceBoundSessionsForRestrictedSites
EnableChromeRefreshTokenBinding
EnableOAuthMultiloginStandardCookiesBinding
EnableOAuthMultiloginStandardCookiesBindingForSecondaryPartitions
</code></pre><p></p><p><code>UseUnexportableKeyServiceInBrowserProcess</code> is the important one. It's the browser-process service that mints the hardware-backed key. With it disabled, Chrome will happily parse a <code>Secure-Session-Registration</code> header, discover it has no way to create a key, and abandon registration. No request to your registration endpoint, no console warning, not even an entry in a <code>chrome://net-export</code> capture. The server sees a perfectly good offer go out and nothing come back, and that's what threw me off when I was trying to test this.</p><p>That also explains why forcing the feature flag on didn't help. In the variations command line, a feature you set yourself appears bare, with no <code><TrialName</code> suffix, so I could confirm <code>DeviceBoundSessions</code> genuinely was enabled. The feature was on; the key service beneath it was off.</p><p>If you need to override a control assignment for testing, quit Chrome completely and launch it with both:</p><pre><code class="language-bash">open -a "Google Chrome" --args \
--enable-features=DeviceBoundSessions,UseUnexportableKeyServiceInBrowserProcess,PersistDeviceBoundSessions
</code></pre><p></p><p>Two gotchas worth knowing. <code>open --args</code> is ignored entirely if Chrome is already running, so quit it first. And Chrome's own "Relaunch" button rebuilds its command line from scratch, discarding anything you passed.</p><p></p><h2 id="where-this-leaves-us">Where this leaves us</h2><p>macOS support is here and it shipped in Chrome 147, but it's arriving gradually and a meaningful slice of users are in a control arm that switches the underlying key service off. If you're implementing DBSC and testing on a Mac, check <code>chrome://version/?show-variations-cmd</code> before you spend an evening debugging your implementation...</p><p>The good news for site operators is that none of this requires anything from you. DBSC degrades cleanly: a browser without support simply ignores the registration header, the binding is never created, and the session carries on as an ordinary cookie session. You can deploy it now and users pick up the protection as their browsers gain it. Over at <a href="https://report-uri.com/?utm_source=scotthelme.co.uk" rel="noreferrer">Report URI</a> in our <a href="https://scotthelme.co.uk/dbsc-beta-at-report-uri/?utm_source=scotthelme.co.uk" rel="noreferrer">beta deployment of DBSC</a>, we've now increased our sample to 10% of users and things continue to go smoothly. As we gain more confidence, we'll keep increasing until 100% of users have DBSC available, and you can see if your current session has DBSC protection on the Settings page 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/08/image.png" class="kg-image" alt="Device Bound Session Credentials lands in Chrome on macOS" loading="lazy" width="922" height="413" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/image.png 922w" sizes="(min-width: 720px) 720px"></figure><p></p><p>The 'Bound' column shows if your session has DBSC protection enabled with more and more customers seeing this as time goes by.</p>]]></content:encoded>
</item>
<item>
<title><![CDATA[Everything I Learned Shipping Device Bound Session Credentials]]></title>
<description><![CDATA[<p>We shipped Device Bound Session Credentials at Report URI, open-sourced the server-side implementation, and then discovered a long list of things the specification doesn't prepare you for.</p><p>Some caused random logouts. One could deadlock a browser tab indefinitely. Two silently turned a device-bound session back</p>]]></description>
<link>https://scotthelme.co.uk/everything-i-learned-shipping-device-bound-session-credentials/</link>
<guid isPermaLink="false">6a6674cb3666810001632ff6</guid>
<category><![CDATA[DBSC]]></category>
<category><![CDATA[chrome]]></category>
<category><![CDATA[cookies]]></category>
<category><![CDATA[PHP]]></category>
<dc:creator><![CDATA[Scott Helme]]></dc:creator>
<pubDate>Mon, 10 Aug 2026 16:06:11 GMT</pubDate>
<media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-lessons.png" medium="image"/>
<content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-lessons.png" alt="Everything I Learned Shipping Device Bound Session Credentials"><p>We shipped Device Bound Session Credentials at Report URI, open-sourced the server-side implementation, and then discovered a long list of things the specification doesn't prepare you for.</p><p>Some caused random logouts. One could deadlock a browser tab indefinitely. Two silently turned a device-bound session back into an ordinary bearer-token session while everything appeared to be working.</p><p>This post is the collection of those production lessons: the bugs, browser behaviours, race conditions and implementation traps I wish we'd known before we started.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?utm_source=scotthelme.co.uk"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/report-uri-logo.png" class="kg-image" alt="Everything I Learned Shipping Device Bound Session Credentials" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h2 id="what-dbsc-actually-does">What DBSC actually does</h2><p>Briefly, because I have a full <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?utm_source=scotthelme.co.uk" rel="noreferrer">explainer blog post on DBSC</a> that you should read:</p><p>Session cookies have one enormous weakness: they're bearer tokens. If malware on a user's machine reads the cookie out of the browser's storage and sends it to an attacker, the attacker is now that user. Every MFA prompt, every device check, every clever thing you did at login has already happened, and the cookie doesn't care. Infostealer malware has industrialised exactly this problem.</p><p>DBSC fixes it by binding the session to a private key the browser generates in hardware — a TPM, a secure enclave — and cannot export. Alongside your normal session cookie there's a second, short-lived cookie. When that cookie expires, the browser <em>defers</em> whatever request needed it, calls a refresh endpoint on your server, proves possession of the device key by signing a challenge, and gets a fresh cookie back. Then, the deferred request resumes.</p><p></p><h2 id="the-spec-reads-backwards">The spec reads backwards</h2><p>Reading the specification, the shape I came away with was: registration is a two-step negotiation, and refresh is a single request. Both our tracking issue and my implementation plan were based on that. Turns out, it's the other way round.</p><p><strong>Registration is single-phase.</strong> You attach a <code>Secure-Session-Registration</code> header to an authenticated response. Chrome generates a key, signs a JWT, and POSTs it to your registration endpoint. You verify it, create the binding, and reply <code>200</code> with the bound cookie. Done. One round trip.</p><p><strong>Refresh is two-phase.</strong> The browser POSTs to your refresh endpoint with no challenge, because it doesn't have one yet. You answer <code>403</code> with a <code>Secure-Session-Challenge</code> header. The browser signs <em>that</em> and POSTs again. Now you answer <code>200</code> with a fresh cookie. Two round trips, and the <code>403</code> is the normal, healthy, everyday path, not an error.</p><p>I know I'm not alone here, because months later the author of a Node DBSC implementation opened an issue on our repo and said, unprompted:</p><blockquote>the 403-then-200 refresh took me an embarrassing amount of time to figure out</blockquote><p>If you take one thing from this post, take the fact that a <code>403</code> on your refresh endpoint is what progress looks like on the way to success.</p><p></p><h2 id="dont-put-the-state-in-your-session-store">Don't put the state in your session store</h2><p>This one is worse, because it fails silently and it fails <em>closed-looking</em>.</p><p>When we first shipped, DBSC state lived where all our other session state lives: in the session, keyed off the session ID. Obvious choice, right? Every request already loads it, it expires when the session expires, the plumbing is free. Easy.</p><p>PHP sessions, and plenty of other session implementations, serialise the entire session as one blob and write the whole thing back. Last writer wins.</p><p>Now look at what the browser does immediately after login:</p><pre><code>POST /login → 200, response carries Secure-Session-Registration
├── GET /account/ (the navigation the user is actually doing)
└── POST /dbsc/register (Chrome, off the back of that header)
</code></pre><p></p><p>Those two run concurrently on the same session. <code>/account/</code> loads the session <em>before</em> registration completes, does its normal work, and writes its pre-registration snapshot back last. The binding we just carefully created got nuked in the process.</p><p>This isn't just a bug, either, it's a security bug. The enforcement gate, finding no binding, concludes there is no DBSC session here and falls back to plain cookie authentication. Which is exactly correct behaviour for a browser that doesn't support DBSC, but exactly wrong here. The user's session is now a bearer token again. No error was logged. Nothing looked broken. Our audit trail showed registrations succeeding, because they had.</p><p>The fix is to give DBSC its own storage, keyed by the session ID but written independently, so a concurrent session write can't clobber it. It's in the library's README as a warning now, phrased about as bluntly as I could manage:</p><blockquote>Report URI shipped DBSC with state in the PHP session blob; the post-login navigation races the <code>/dbsc/register</code> POST, both rewrite the whole blob last-writer-wins, the binding is clobbered, and enforcement silently no-ops — leaving exactly the stolen-cookie hole DBSC exists to close.</blockquote><p>If you're implementing this: your DBSC binding needs its own key. Not a field in an existing blob you rewrite wholesale.</p><p></p><h2 id="everything-rotates-or-the-browser-terminates-you">Everything rotates, or the browser terminates you</h2><p>Three related rules, all learned the same way, all now baked into the library.</p><p><strong>Rotate the cookie value on every refresh.</strong> If you verify the refresh JWT and reply <code>200</code> with the same cookie value the browser already has, because nothing has changed, so why not, Chrome reads that as "no refresh happened" and terminates the session. It wants to see rotation as proof the server actually did something I guess.</p><p><strong>Rotate the challenge on every refresh too.</strong> Same reasoning. A refresh that doesn't advance the challenge hasn't advanced anything.</p><p><strong>Do not put a challenge on the registration response.</strong> This one is properly counter-intuitive: it looks like an easy optimisation to hand the browser its first challenge on the same response that creates the session, saving that first <code>403</code>. Chrome reports a Challenge Error and the session never gets going.</p><p></p><p>The reason is buried in two separate sections of the spec, and I only really understood it a month later when I was arguing about test vectors with that Node implementer. A <code>Secure-Session-Challenge</code> carries an <code>id</code> parameter naming which session it belongs to, and a challenge whose session can't be identified is silently dropped. But the registration response is the response that <em>creates</em> the session. At the moment it's parsed, there's nothing for the <code>id</code> to name. So the challenge resolves to nothing and Chrome, I guess quite reasonably, complains.</p><p>I tried it, it didn't work, I reverted it, and there is now a test in the library whose name is literally <code>register response has NO Secure-Session-Challenge (Chrome rejects it there)</code>, because I did not want anyone (including future Scott with terrible memory) to "optimise" it back in.</p><p>There's a fourth rule in the same family that's less about Chrome and more about arithmetic: <strong>your challenge TTL must be longer than your cookie lifetime.</strong> The browser caches a challenge. If it caches one just before the cookie expires, and the challenge TTL is shorter, the challenge is dead by the time there's a reason to use it. Our library's config constructor now refuses to build with the values the wrong way round.</p><p></p><h2 id="the-bug-you-cannot-see-on-localhost">The bug you cannot see on localhost</h2><p>This is my favourite one, and it's the most transferable lesson here even if you never touch DBSC.</p><p>The bound cookie rotates on every refresh. Fine. But rotation is not instantaneous <em>from the browser's point of view</em>. The refresh is a round trip, and during that round trip the browser is still doing other things.</p><pre><code>t+0ms browser starts POST /dbsc/refresh
t+205ms browser dispatches GET /ajax/some-widget ← carries the OLD cookie. Correctly.
t+1235ms refresh response lands, browser stores the NEW cookie
</code></pre><p></p><p>That widget request left the browser 205ms into a 1,235ms refresh. It carried the pre-rotation cookie value because that was, at that instant, the only value the browser had. It is a completely legitimate request from a completely legitimate session.</p><p>Our enforcement gate compared the presented cookie against the stored one, found a mismatch, and concluded: stolen cookie. Terminate the session, revoke the binding, log the user out.</p><p>We shipped that and then beta users started getting randomly logged out.</p><p>The signature in our audit trail was a successful refresh followed about a second later by an enforcement termination, with <em>no</em> refresh failure between them, which is what told us the fault was in the gate, not the refresh path. We caught it properly with a request trace showing exactly the sequence above.</p><p>Here's the part that makes it dangerous: <strong>the failure rate is proportional to latency.</strong> The window is exactly the refresh round-trip time. On a developer's loopback interface that's a couple of milliseconds and you will basically never see it. We left it running on dev for two hours before it fired even once. Behind a CDN, over a real network, it's a second or more, and it fires on almost every refresh that races an ordinary request. Which is most of them, on a busy page.</p><p>It passed every test we have, but it broke in production because production has physics.</p><p>The fix is a single-depth history: accept the immediately-previous cookie value, but only until the instant that value would itself have expired in the browser anyway. I want to draw attention to that second clause, because it's a small design point I'm quite pleased with. There is no grace constant. No "give it five seconds and see". The acceptance window is exactly the lifetime the browser itself would still send that value for. It's a real quantity, derived from the system and it expires on its own without anyone having to tune it.</p><p>And the security exposure is genuinely bounded too. One generation deep, expiring naturally, and a genuinely stolen cookie still can't complete a refresh without the device key, so it still hard-fails within minutes.</p><p></p><h2 id="never-redirect-a-dbsc-endpoint">Never redirect a DBSC endpoint</h2><p>Now, the big one. Our DBSC endpoints inherited the standard authentication gate that sits in front of every authenticated route on our application. Sensible reuse. That gate does what every such gate does: if you're not authenticated, you get a redirect to the login page.</p><p>Consider what happens when a session finally expires while a tab sits idle:</p><ol><li>The tab wakes up and requests a page.</li><li>The bound cookie has expired, so Chrome <strong>defers</strong> that navigation and calls <code>/dbsc/refresh</code> first.</li><li>Our auth gate sees an expired session and answers the refresh with <code>302 → /login</code>.</li><li>Chrome... stops.</li></ol><p>Not "gives up". Not "terminates the session and continues". It deadlocks. The deferred navigation is never released, never times out, and never fails. Blank tab, preliminary headers, forever.</p><p>We proved it with a two-state matrix, forcing each state deliberately rather than waiting for a weekend to elapse:</p><p></p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>App session</th>
<th>DBSC binding</th>
<th><code>/dbsc/refresh</code> answers</th>
<th>Chrome</th>
</tr>
</thead>
<tbody>
<tr>
<td>expired</td>
<td>present</td>
<td><strong>302 → /login</strong></td>
<td><strong>deadlocks</strong> — deferred navigation never resumes</td>
</tr>
<tr>
<td>alive</td>
<td>deleted</td>
<td><strong>401</strong></td>
<td><strong>recovers</strong> — terminates the DBSC session, navigation continues to /login</td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<p></p><p>Same broken-session situation, same user experience intent, completely different outcome based purely on the status code. A <code>401</code> tells Chrome the session is over, it tears the DBSC session down, and the deferred navigation is released to do what it was always going to do and land on the login page. A <code>302</code> tells Chrome nothing it can act on, and it waits.</p><p>I feel like this is a bug so we raised it in Chromium as <a href="https://issues.chromium.org/issues/534027936?utm_source=scotthelme.co.uk" rel="noreferrer">534027936</a>.</p><p>Our fix is the bit I'd encourage you to copy. The obvious patch is to add a guard: <em>if this is a DBSC endpoint and the auth gate is about to redirect, send a 401 instead.</em> </p><p>The DBSC endpoints now override the redirect mechanism itself to answer <code>401</code>. Not "this gate doesn't redirect DBSC requests" but "<strong>this endpoint cannot emit a redirect at all</strong>". Every existing gate is covered, and, more importantly, so is every gate anyone adds in the next five years without knowing any of this.</p><p></p><h2 id="a-challenge-mismatch-is-not-an-attack">A challenge mismatch is not an attack</h2><p>The most important fix in the library didn't come from us. It came from a contributor in the Netherlands with production logs from his own Chrome 150 install, showing real users being logged out.</p><p>His trace, near enough:</p><pre><code>20:29:33 refresh succeeded, new challenge issued
── 16 minutes idle ──
20:45:40 refresh: challenge expired → session revoked path
20:45:41 refresh: challenge mismatch → session revoked path
20:45:41 enforcement terminated, user logged out mid-flow
</code></pre><p></p><p>His challenge TTL was 900 seconds. The challenge presented at 20:45:40 had been issued 967 seconds earlier. So the first refresh legitimately failed as expired, which is a benign, retriable outcome, and we handled it correctly by minting a fresh challenge and answering <code>403</code>.</p><p>One second later the browser came back. And it presented a challenge the server had <em>already rotated past</em>. That's a mismatch, and we treated a mismatch the way you'd expect: as a failed cryptographic proof. Stolen cookie. Revoke everything. Nuke it from orbit. Turns out though, that's wrong.</p><p>The signature is verified before the challenge is compared. The refresh handler verifies the JWT against the device-bound public key first. Only if that passes does it look at which challenge was signed. So <em>anything that reaches the mismatch branch has already proved possession of the device's private key.</em> It cannot be forgery as a forgery dies earlier, at the signature check, and still terminates the session exactly as it should.</p><p>This means a mismatch can only ever be one of a small set of benign situations:</p><ol><li><strong>Concurrent refreshes.</strong> Two requests fire on return from idle, a service worker fetch racing a main navigation perhaps, and both holding the same cached challenge. The first succeeds and rotates. The second is correctly signed and now stale.</li><li><strong>A lost response and a retry.</strong> The browser signed a challenge, the response never arrived, so it tries again. There is no client-side fix for this, it's just a reality of the network being unreliable.</li><li><strong>A challenge-delivery race of your own making</strong>, if like us you have more than one path that can hand the browser a challenge.</li></ol><p></p><p>So mismatch, along with missing and expired, is now a benign, retriable outcome. Mint a fresh challenge, answer <code>403</code>, let the browser try again. Bad signatures remain terminal and unchanged.</p><p>Another thing I liked was how this converges under concurrency, which the previous single-generation overlap window could not do. Three concurrent refreshes, all holding challenge <code>C</code>:</p><pre><code>A(C) → 200, cookie rotates, challenge now C2
B(C) → benign 403, mint C3 browser now signs C3
D(C) → benign 403, mint C4 browser now signs C4
B′(C3) → matches the previous value → 200, cookie rotates again
D′(C4) → C4 is now two generations back, overlap consumed → benign 403, mint C6
D″(C6) → 200. Converged.
</code></pre><p></p><p>Everybody gets there and nobody gets logged out. The cost is one extra round trip per concurrency event (which is a bargain compared to the cost of a support ticket). And note that this handles <em>unbounded</em> concurrency, whereas the overlap window alone only ever covered two-deep. Three simultaneous requests were enough to trip a spurious logout under the old behaviour.</p><p>The general point that I keep coming back to is: A single-use nonce sent over a lossy, concurrent transport will sometimes be presented stale. That is intrinsic to the design and not a defect in it. The bug was never that mismatches happened. The bug was terminating on an outcome that is inherent, benign, and tells you nothing about attackers. </p><p></p><h2 id="your-sso-logins-probably-arent-binding-at-all">Your SSO logins probably aren't binding at all</h2><p>Chrome issues the registration POST in the `SameSite` context of the navigation that carried the registration header. That's fine for a normal login on your site, the user POSTed a form to you, the response is same-site, the POST carries your session cookie. All good.</p><p>A SAML login doesn't work like that. The user lands on your callback via a chain the IdP initiated, so the registration POST that Chrome makes off that response counts as cross-site, and a <code>Lax</code> session cookie is withheld from it. The request arrives unauthenticated and gets a <code>401</code>.</p><pre><code>22:47:20 GET /account/ 200 ← Lax rides a top-level navigation
22:47:20 POST /dbsc/register 401 ← Lax does not ride this POST
</code></pre><p></p><p>And it's not merely a failure, it's a <em>spent</em> failure. Chrome marks the session as having a persistent HTTP error and won't retry. Offering the header on the SSO response doesn't just fail to bind that login; it burns the only attempt you get.</p><p>The fix is to record the intent rather than act on it: mark that this login should be bound, then make the actual offer on the first document request that isn't cross-site, which is typically the very next page the user loads. We detect that with <code>Sec-Fetch-Site</code>, and treat a missing header as "don't bother", on the grounds that a client not sending <code>Sec-Fetch-Site</code> isn't going to register anyway.</p><p></p><h2 id="fail-open-at-the-edges-fail-closed-at-the-gate">Fail open at the edges, fail closed at the gate</h2><p>DBSC has a lovely property, which is that adopting it cannot lock anyone out. A browser that doesn't support it ignores the registration header, never registers, and your gate, finding no binding, degrades to ordinary cookie authentication. Locking a current Firefox user out is <em>structurally impossible</em>. You don't need a compatibility check or a user-agent sniff; the protocol does it for you.</p><p>That's the correct behaviour, and the library goes out of its way not to break it. But it has an ugly failure mode when it meets bad data.</p><p>We had a routine that parsed a stored binding and returned <code>null</code> if it couldn't. Perfectly ordinary defensive code. Except the gate reads <code>null</code> as "there's no binding here", which, per the paragraph above, means "degrade gracefully to cookie auth".</p><p>So a <em>corrupt</em> binding didn't fail closed. It quietly downgraded a hardware-bound session to a bearer token, which is the entire thing we implemented DBSC to prevent! </p><p>It's not client-triggerable, to be clear, the realistic causes are internal: a serialiser mismatch, a truncated value, a schema skew across a deploy. But "only happens during a deploy" is not much comfort when the failure mode is silently disabling your session protection, and deploys are exactly when things are weird.</p><p>Now it throws, <code>null</code> means "no record", and only that. Present-but-unparseable is a distinct, loud, fail-closed condition. The distinction is documented in the storage interface, so anyone implementing their own backend knows which is which.</p><p>There's exactly one place we deliberately do the opposite, and I think the reasoning holds. Our "manage your sessions" screen shows a badge for whether each session is device-bound. If one row's binding can't be read, failing the whole page closed would mean a 500 for a page that is a <em>viewer</em>, not an enforcement point. So that badge is tri-state — bound, not bound, and unknown — and a bad read degrades to "unknown", surfaced as a visible alert rather than a silent "no".</p><p></p><h2 id="some-decisions-id-defend">Some decisions I'd defend</h2><p>A few smaller calls that I think generalise.</p><p><strong>Two overlap windows, deliberately asymmetric.</strong> We keep a one-generation history for the cookie <em>across</em> a successful refresh, but we explicitly discard the previous challenge on a successful refresh. That looks inconsistent, and it isn't: the refresh <code>200</code> delivers the new challenge synchronously with the new cookie, so there's no propagation window to bridge on that path — and <em>not</em> keeping it stops a spent challenge from being replayable. There's a comment in the source that says, more or less, "this asymmetry is intentional, do not consistency-refactor these into one," because I could see exactly how that tidy-up would go.</p><p><strong>No Web Crypto fallback.</strong> We were asked about supporting non-Chromium browsers with a software key, and I chose not to. The entire value of DBSC for me is the hardware-binding guarantee, and a software-bound key trades that away. A browser without DBSC degrading cleanly to plain cookie auth is expected. A browser holding a software key that your code treats as hardware-bound is not.</p><p><strong>Don't validate optional JWT claims speculatively.</strong> We verify the signature and the challenge. We deliberately do not check <code>iat</code>, <code>exp</code>, <code>typ</code>, <code>iss</code> or <code>aud</code>. The draft lists them as optional, browser emission isn't stable across versions, and our challenge TTL is already stricter than any <code>exp</code> a browser would plausibly emit. Adding checks the spec doesn't require buys you nothing, and we can tighten later if a revision mandates it.</p><p><strong>Set a content type even on empty responses.</strong> Small one. Our <code>403</code> challenge response has no body, but it declares <code>application/json</code> anyway — because in development a framework debug bar will cheerfully inject HTML into a response the browser is parsing strictly, and you will spend an hour on that. Ask me how I know.</p><p></p><h2 id="where-it-stands">Where it stands</h2><p>DBSC is now in open-beta at Report URI, it is being applied to a random downsample of our users that is increasing over time. The library is <a href="https://github.com/report-uri/dbsc-php?utm_source=scotthelme.co.uk">report-uri/dbsc-php</a> if you want it, or just want to read the comments, and most of what's above is in there, next to the code it explains.</p><p>DBSC is genuinely good. It closes a real hole that MFA doesn't touch, and it does it without any risk of locking users out. I'd encourage anyone running sessions at scale to look at it.</p><p>Just don't redirect the refresh endpoint 😅</p>]]></content:encoded>
</item>
<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'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>]]></description>
<link>https://scotthelme.co.uk/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'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/?utm_source=scotthelme.co.uk">WICG proposal</a> (<a href="https://github.com/WICG/connection-allowlists?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk"><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
</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">{
"type": "connection-allowlist",
"body": {
"url": "https://report-uri.com/account",
"connection": "https://blocked.example/collect.js",
"allowlist": ["https://api.example.com/*"],
"disposition": "report"
}
}</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?utm_source=scotthelme.co.uk">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>]]></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/?utm_source=scotthelme.co.uk" 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>]]></description>
<link>https://scotthelme.co.uk/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 – 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">Tranco</a> Top 1 Million (819,002 responding sites), powered by <a href="https://crawler.ninja/?utm_source=scotthelme.co.uk">Crawler.Ninja</a> and <a href="https://report-uri.com/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk"><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/?utm_source=scotthelme.co.uk" 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>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Certificate Authority</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<tr>
<td>Google Trust Services — WE1</td>
<td>193,069</td>
</tr>
<tr>
<td>Let's Encrypt — R12</td>
<td>59,705</td>
</tr>
<tr>
<td>Let's Encrypt — R13</td>
<td>59,496</td>
</tr>
<tr>
<td>Let's Encrypt — E8</td>
<td>48,754</td>
</tr>
<tr>
<td>Let's Encrypt — E7</td>
<td>48,564</td>
</tr>
<tr>
<td>Let's Encrypt — YE2</td>
<td>23,702</td>
</tr>
<tr>
<td>Let's Encrypt — YE1</td>
<td>23,467</td>
</tr>
<tr>
<td>Sectigo — Public Server Authentication CA DV R36</td>
<td>19,879</td>
</tr>
<tr>
<td>Let's Encrypt — YR1</td>
<td>19,159</td>
</tr>
<tr>
<td>Let's Encrypt — 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'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'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 – 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Validity period</th>
<th>Certificates</th>
<th>Share</th>
</tr>
</thead>
<tbody>
<tr>
<td>≤ 47 days</td>
<td>1,692</td>
<td>0.3%</td>
</tr>
<tr>
<td>48–90 days</td>
<td>509,744</td>
<td>77.4%</td>
</tr>
<tr>
<td>91–200 days</td>
<td>49,743</td>
<td>7.6%</td>
</tr>
<tr>
<td>201–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 — 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">why we need shorter lifetimes</a>, through <a href="https://scotthelme.co.uk/ballot-sc22-reduce-certificate-lifetimes/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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>
<!--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'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 — 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/?utm_source=scotthelme.co.uk" 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>
<!--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 – 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 – 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>
<!--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 — 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>
<!--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 – 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><em>Crawler.Ninja</em></a><em> and </em><a href="https://report-uri.com/?utm_source=scotthelme.co.uk"><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'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>]]></description>
<link>https://scotthelme.co.uk/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 – 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">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>
<!--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'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>
<!--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+ — 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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> ≥ 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'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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" rel="noreferrer">Report URI</a> and we'll make it easy. <a href="https://scotthelme.co.uk/goodbye-feature-policy-and-hello-permissions-policy/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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 – 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?utm_source=scotthelme.co.uk" 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-…</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've updated the crawler to track the <a href="https://scotthelme.co.uk/coop-and-coep/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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 – 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" rel="noreferrer">blogged about it being removed</a> back in 2020.</li><li><a href="https://scotthelme.co.uk/what-the-floc/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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>
<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'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>
<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/?utm_source=scotthelme.co.uk">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>—</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 — 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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><em>Crawler.Ninja</em></a><em> and </em><a href="https://report-uri.com/?utm_source=scotthelme.co.uk"><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/?utm_source=scotthelme.co.uk" 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>]]></description>
<link>https://scotthelme.co.uk/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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><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
</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
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'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>'
<title>Snapinsta - Download Instagram Videos, Reels, Stories for FREE</title>
</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
104.21.72.58
172.67.175.240
</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 \
-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 — 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?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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>]]></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/?utm_source=scotthelme.co.uk">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>]]></description>
<link>https://scotthelme.co.uk/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't Support Them"><p>Back in 2017, Troy Hunt and I built a little website called <a href="https://whynohttps.com/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk"><strong>whynopasskeys.com</strong></a></p><p></p><figure class="kg-card kg-image-card"><a href="https://whynopasskeys.com/?utm_source=scotthelme.co.uk"><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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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>]]></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.co.uk/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/?utm_source=scotthelme.co.uk"><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>
<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'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>
</tr>
<tr>
<td><strong>25 Apr 2026</strong></td>
<td>A Canvas customer-service representative opens the ticket. The payload fires <em>"in the rep's authenticated session."</em></td>
</tr>
<tr>
<td><strong>28–30 Apr 2026</strong></td>
<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>
</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 — 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>
</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'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:
default-src 'self';
script-src 'self' 'nonce-{random}';
style-src 'self' 'nonce-{random}';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">Trend Micro — What Is the Instructure Canvas Breach?</a><br><a href="https://ailearninsights.substack.com/p/what-can-we-infer-from-canvass-technical">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk"><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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">2026 Canvas security incident — 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’ve open-sourced <a href="https://packagist.org/packages/report-uri/dbsc-php?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><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>]]></description>
<link>https://scotthelme.co.uk/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’ve open-sourced <a href="https://packagist.org/packages/report-uri/dbsc-php?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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};
$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?utm_source=scotthelme.co.uk" rel="noreferrer">report-uri/dbsc-php</a><br>Source & docs: <a href="https://github.com/report-uri/dbsc-php?utm_source=scotthelme.co.uk">https://github.com/report-uri/dbsc-php</a><br>The spec: <a href="https://github.com/w3c/webappsec-dbsc?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><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.co.uk/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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><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/?utm_source=scotthelme.co.uk">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>]]></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’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>]]></description>
<link>https://scotthelme.co.uk/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’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>
<!--kg-card-begin: html-->
<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?utm_source=scotthelme.co.uk">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.co.uk">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'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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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
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">{
"session_identifier": "9c2b7f3e1a",
"refresh_url": "/dbsc/refresh",
"scope": {
"origin": "https://report-uri.com",
"include_site": false
},
"credentials": [
{
"type": "cookie",
"name": "dbsc",
"attributes": "Path=/; Secure; HttpOnly; SameSite=Lax"
}
]
}</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
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: "def456"; id="9c2b7f3e1a"
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: "ghi789"; id="9c2b7f3e1a"
Sec-Secure-Session-Id: 9c2b7f3e1a
Content-Type: application/json
Content-Length: 312</code></pre><pre><code class="language-json">{
"session_identifier": "9c2b7f3e1a",
"refresh_url": "/dbsc/refresh",
"scope": {
"origin": "https://report-uri.com",
"include_site": false,
"scope_specification": []
},
"credentials": [
{
"type": "cookie",
"name": "dbsc",
"attributes": "Path=/; Secure; HttpOnly; SameSite=Lax"
}
]
}</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"
</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
Content-Type: text/html
Secure-Session-Challenge: "ghi789"; id="9c2b7f3e1a"
<html>
...
</html>
</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/?utm_source=scotthelme.co.uk#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)?utm_source=scotthelme.co.uk" rel="noreferrer">announcement</a>), and it looks like we could get it soon on <a href="https://chromestatus.com/feature/5140168270413824?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" rel="noreferrer">Device Bound Session Credentials (DBSC) | Chrome for Developers</a><br><a href="https://developer.chrome.com/blog/dbsc-windows-announcement?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" rel="noreferrer">Origin trial: Device Bound Session Credentials in Chrome | Chrome for Developers</a><br><a href="https://www.w3.org/TR/dbsc/?utm_source=scotthelme.co.uk" rel="noreferrer">Device Bound Session Credentials (W3C draft spec)</a><br><a href="https://github.com/w3c/webappsec-dbsc?utm_source=scotthelme.co.uk" 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.co.uk/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'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/?utm_source=scotthelme.co.uk"><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 = () => {
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 && (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'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'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)
? (/* 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'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?utm_source=scotthelme.co.uk" 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) {
const pp = document.permissionsPolicy || document.featurePolicy;
if (pp && !pp.allowsFeature('publickey-credentials-create')) {
throw new DOMException(
'The operation is not allowed by the document Permissions Policy.',
'NotAllowedError'
);
}
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'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
Object.defineProperty(document, 'featurePolicy', {
get: () => ({ allowsFeature: () => 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'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 = {
'create-credential': 'publickey-credentials-create',
'get-credential': 'publickey-credentials-get',
};
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 → 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 — fail closed — before any message reaches the
// background service worker or the native app.
function handleBridgeRequest(msg) {
if (!permissionsPolicyAllows(msg.name)) {
return respond(msg, {
type: 'create-credential-error',
data: { reason: 'permissions-policy-denied' },
});
}
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'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/?utm_source=scotthelme.co.uk" 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, {
configurable: false,
enumerable: true,
get() { return newMethod; }, // always returns 1P'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'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/?utm_source=scotthelme.co.uk" 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;
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 — but not the container.
const fakeContainer = new Proxy(realContainer, {
get(target, prop) {
if (prop === 'create') return hijackCreate;
const value = Reflect.get(target, prop, target);
return typeof value === 'function' ? value.bind(target) : value;
},
});
const shadow = { configurable: true, enumerable: true, get() { return fakeContainer; } };
try {
Object.defineProperty(Navigator.prototype, 'credentials', shadow);
installed = navigator.credentials === fakeContainer;
} catch (e) { /* try the instance next */ }
if (!installed) {
try {
Object.defineProperty(navigator, 'credentials', shadow);
installed = navigator.credentials === fakeContainer;
} catch (e) { /* give up */ }
}
}</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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" rel="noreferrer">Permissions Policy</a> and <a href="https://report-uri.com/products/content_security_policy?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" rel="noreferrer">2024</a>, <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" 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.co.uk" rel="noreferrer">release date</a> 15th May 2026<br>Bridge Spoof PoC (same-origin script): <a href="https://report-uri-demo.com/passkeys/5/?utm_source=scotthelme.co.uk" rel="noreferrer">link</a><br>Bridge Spoof PoC (third-party script): <a href="https://report-uri-demo.com/passkeys/6/?utm_source=scotthelme.co.uk" rel="noreferrer">link</a><br>Wrapper override PoC: <a href="https://report-uri-demo.com/passkeys/3/?protected&ref=scotthelme.co.uk" 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>
</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.co.uk/</link><image><url>https://scotthelme.co.uk/favicon.png</url><title>Scott Helme</title><link>https://scotthelme.co.uk/</link></image><generator>Ghost 6.64</generator><lastBuildDate>Fri, 11 Sep 2026 01:47:50 GMT</lastBuildDate><atom:link href="https://scotthelme.co.uk/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[No Hacking Required: The Manchester Airports Group Data Breach]]></title><description><![CDATA[<p>On 27 August 2026, Manchester Airports Group told customers that "an unauthorised third party" had stolen their data. Car park bookings, lounge bookings, Fast Track purchases for airport security and passport control, along with airport WiFi sign-ups across Manchester, Stansted and East Midlands. Roughly 8.8 million</p>]]></description><link>https://scotthelme.co.uk/no-hacking-required-manchester-airports-group-data-breach/</link><guid isPermaLink="false">6a9b30371c91af0001bd2b4f</guid><category><![CDATA[FulcrumSec]]></category><category><![CDATA[MAG]]></category><category><![CDATA[javascript]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 07 Sep 2026 09:13:49 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/mag.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/mag.png" alt="No Hacking Required: The Manchester Airports Group Data Breach"><p>On 27 August 2026, Manchester Airports Group told customers that "an unauthorised third party" had stolen their data. Car park bookings, lounge bookings, Fast Track purchases for airport security and passport control, along with airport WiFi sign-ups across Manchester, Stansted and East Midlands. Roughly 8.8 million people.</p><p>A few days later, the group calling itself FulcrumSec claimed it, and said something that caught my eye. They said they didn't hack anything. They said they simply read an API key out of the website's JavaScript.</p><p>That's a very specific, very checkable claim. So I checked it.</p><p>Everything they described was there. Three keys, one per airport, in the page source, unrotated for over four years, for anyone to see.</p><p></p><h2 id="what-mag-said">What MAG said</h2><p>MAG's <a href="https://www.manchesterairport.co.uk/help/data-security-incident/?utm_source=scotthelme.co.uk">incident page</a> is what you'd expect. Email addresses, phone numbers, vehicle registrations and postcodes were taken. No bank or payment details. The incident was "immediately contained", specialist advisors were engaged, authorities were notified, and at no point was passenger safety or aviation security 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/09/MAG_Manchester_Airport_logo.svg" class="kg-image" alt="No Hacking Required: The Manchester Airports Group Data Breach" loading="lazy" width="186" height="79"></figure><p></p><p>What it doesn't say is <em>how</em>.</p><p>That's not unusual, and I'm not going to beat them up for it, but when the attacker makes a technical claim and the victim says nothing, the claim stands unchallenged, and I'd rather know whether it's true.</p><p></p><h2 id="what-fulcrumsec-said">What FulcrumSec said</h2><p><a href="https://fulcrumsec.vg/mag/?utm_source=scotthelme.co.uk" rel="noreferrer">Their claim</a>, as reported, was that they "obtained access using airport-specific Iterable API credentials exposed in client-side JavaScript." The first few times I read that, I read it as "iterable", as in it could be iterated/enumerated. Not as "Iterable", the brand!</p><p></p><ol><li><strong>Iterable</strong> — a marketing automation platform. Customer profiles, segmentation, campaign history.</li><li><strong>Airport-specific</strong> — plural credentials, one per airport, not one shared key.</li><li><strong>Client-side JavaScript</strong> — shipped to the browser. Public by construction.</li></ol><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/09/fulcrum-sec.png" class="kg-image" alt="No Hacking Required: The Manchester Airports Group Data Breach" loading="lazy" width="2000" height="667" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/09/fulcrum-sec.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/09/fulcrum-sec.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/09/fulcrum-sec.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/fulcrum-sec.png 2172w" sizes="(min-width: 720px) 720px"></figure><p></p><p>If all three are true, this isn't a breach in the way people picture one. There's no intrusion, no lateral movement, no malware. It's someone opening DevTools, good old 'F12 is hacking' stuff. </p><p></p><h2 id="where-to-look">Where to look</h2><p>MAG's three airport sites are Next.js applications served from CloudFront:</p><pre><code>www.manchesterairport.co.uk → man.live.mag-dxp.com
www.stanstedairport.com → stn.live.mag-dxp.com
www.eastmidlandsairport.com → ema.live.mag-dxp.com
</code></pre><p></p><p>I started with the obvious thing: curl the homepage and grep it. Nothing. The HTML mentions Iterable, but only as CMS field names like <code>iterableEventName</code>, <code>trackWithIterable</code>, <code>iterableCampaignId</code>. Configuration for which marketing event a signup form fires. No credentials. Same story in <code>__NEXT_DATA__</code>.</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/09/iterable-logo-auto.svg" class="kg-image" alt="No Hacking Required: The Manchester Airports Group Data Breach" loading="lazy" width="135" height="20"></figure><p></p><p>This is the tricky thing with problems like this; if you only look at the HTML, you find nothing. The secret isn't in the DOM. It's in a JavaScript bundle the document loads, one of about ninety chunk files with content-hashed names, and people don't often read those. Not the developer who shipped it, not the reviewer, and not whatever scanner MAG was running, clearly.</p><p></p><p>So I pulled all ninety and grepped those instead. Seventeen of them mention Iterable, and this is what's in them:</p><pre><code class="language-js">function (e) {
let t = `${n?.IterableApi?.Url}/api/users/${e}`;
return r.A.get(t, { headers: { "Api-Key": n?.IterableApi?.Key } })
.then(e => {
e.data.user && window.sessionStorage.setItem("IterableUID", e.data.user.userId)
})
}
</code></pre><p>And alongside it:</p><pre><code class="language-js">function (e) {
let t = `${n?.IterableApi?.Url}/api/events/track`;
return r.A.post(t, e, { headers: { "Api-Key": n?.IterableApi?.Key } })
}
</code></pre><p></p><p>There it is. The browser makes a <code>GET</code> to <code>https://api.iterable.com/api/users/{email}</code> with an <code>Api-Key</code> header, and gets back a user object.</p><p>That first call looks really bad. It takes an email address and returns that person's profile. It's a server-side credential doing a server-side job, from inside the browser on the client-side, on a normal page load, for anyone who cared to look.</p><p>The config object that feeds it lives in <code>pages/_app-*.js</code>:</p><pre><code class="language-json">"IterableApi": { "Key": "", "Url": "https://api.iterable.com" }
</code></pre><p></p><p>It's now empty. Which tells you two things: the mechanism is exactly what FulcrumSec described, and somebody has since removed the key.</p><p>An empty string is not evidence of what used to be there, though. For that I needed the history. </p><p></p><h2 id="the-internet-never-forgets">The Internet never forgets</h2><p>The Internet Archive's CDX API indexes far more than homepages. It has JavaScript, and MAG's <code>_app</code> chunk has been captured for years:</p><pre><code class="language-bash">curl "http://web.archive.org/cdx/search/cdx?\
url=manchesterairport.co.uk&matchType=domain&output=text\
&fl=timestamp,original,statuscode,digest\
&filter=original:.*chunks/pages/_app-.*\
&filter=statuscode:200&collapse=digest"
</code></pre><p></p><p>393 unique captures for Manchester going back to 2020, along with 445 for Stansted, and 208 for East Midlands.</p><p>Fetch any one of them with the <code>id_</code> modifier, which serves the original bytes rather than Wayback's rewritten version, decompress it, and grep away:</p><pre><code class="language-bash">curl -s "https://web.archive.org/web/20260802022711id_/\
https://www.manchesterairport.co.uk/_next/static/chunks/pages/_app-6c91337bee847d36.js" \
| python3 -c "import sys,brotli,re; \
s=brotli.decompress(sys.stdin.buffer.read()).decode(); \
print(re.search(r'\"IterableApi\":\{[^}]*\}', s).group(0))"
</code></pre><pre><code class="language-json">"IterableApi": { "Key": "5273…517c", "Url": "https://api.iterable.com" }
</code></pre><p></p><p>A 32-character hexadecimal Iterable API key, in a file Manchester Airports Group had been <strong>serving to the public since 2022</strong>.</p><p></p><h2 id="three-keys-over-four-years">Three keys, over four years</h2><p>I sampled every quarter from 2020 to today across all three domains, then dug deeper to find the exact dates. Here's the timeline.</p><p></p><table>
<thead>
<tr>
<th>Site</th>
<th>Key</th>
<th>Absent</th>
<th>First seen exposed</th>
<th>Last seen exposed</th>
<th>Emptied by</th>
</tr>
</thead>
<tbody>
<tr>
<td>East Midlands</td>
<td><code>c72b…0086</code></td>
<td>2022-06-05</td>
<td><strong>2022-06-23</strong></td>
<td>2026-08-16</td>
<td>no capture after</td>
</tr>
<tr>
<td>Stansted</td>
<td><code>5969…ccf7</code></td>
<td>2022-06-16</td>
<td><strong>2022-06-28</strong></td>
<td>2026-08-23</td>
<td><strong>2026-08-25 20:48</strong></td>
</tr>
<tr>
<td>Manchester</td>
<td><code>5273…517c</code></td>
<td>2022-07-03</td>
<td><strong>2022-07-11</strong></td>
<td>2026-08-22</td>
<td>2026-08-27 21:49</td>
</tr>
</tbody>
</table>
<p></p><p>Three distinct keys, one per airport, just as FulcrumSec had said, it was "airport-specific".</p><p>The keys appear during a rollout in June and July 2022, and then <strong>the exact same value is present in every single capture until August 2026</strong>. They were <em>never</em> rotated. Not once, in more than four years.</p><p>Read the timeline the other way round and it's worse. Anyone who looked at that page source on any day between June 2022 and August 2026 could have taken the key. FulcrumSec just happen to be the ones who told us. There is no way to know, from the outside, who else did, and the honest answer is that MAG can't know either without going back through four years of Iterable API logs, if they even have four years of Iterable API logs.</p><p>One detail that does stand out in this, though: Stansted's key went empty by 25 August,<strong> at least two days before MAG went public.</strong> That sounds like a normal incident response, find it, kill it, then disclose, but it does put a date on when they knew.</p><p>For East Midlands, the Internet Archive has no capture of that chunk after 16 August, so I can't precisely date its remediation. It was empty when I looked, though, and that we can prove.</p><p></p><h2 id="what-the-key-could-actually-do">What the key could actually do</h2><p>Having been a MAG customer many times myself, I'm in the breach many times over, as confirmed by <a href="https://haveibeenpwned.com/?utm_source=scotthelme.co.uk" rel="noreferrer">HIBP</a>, so I've had these API keys in my browser and sent these exact requests many times before! All you had to do was visit the right page and your browser would send the request as intended. </p><p></p><pre><code class="language-bash">scott@Gaming-Rig:~/code$ curl -s "https://api.iterable.com/api/users/mag%40scotthelme.co.uk" -H "Api-Key: 527snip17c" | jq .
{
"msg": "Disabled API key",
"code": "Unauthorized",
"params": {
"ip": "snip",
"endpoint": "/api/users/mag%40scotthelme.co.uk"
}
}
scott@Gaming-Rig:~/code$ curl -s "https://api.iterable.com/api/users/mag%40scotthelme.co.uk" -H "Api-Key: 596snipcf7" | jq .
{
"msg": "Disabled API key",
"code": "Unauthorized",
"params": {
"ip": "snip",
"endpoint": "/api/users/mag%40scotthelme.co.uk"
}
}
scott@Gaming-Rig:~/code$ curl -s "https://api.iterable.com/api/users/mag%40scotthelme.co.uk" -H "Api-Key: c72snip086" | jq .
{
"msg": "Disabled API key",
"code": "Unauthorized",
"params": {
"ip": "snip",
"endpoint": "/api/users/mag%40scotthelme.co.uk"
}
}</code></pre><p></p><p>I'm glad to see that the keys are disabled at least, it means I don't have to reach out to Iterable to disclose and that the breach did stop when the keys were pulled from the production site.</p><p>You didn't need curl to test it anyway. The site's own code calls <code>GET /api/users/{email}</code> with this key and reads <code>data.user.userId</code> off the response. That path returns a user's profile. So the key had, at minimum, read access to customer records by email address.</p><p>Given that, the rest follows without any further cleverness. Feed it a list of addresses and you're enumerating a customer database over HTTPS from a coffee shop. FulcrumSec described roughly 86GB compressed, extracting to around 640GB, including a 21.5GB Manchester customer export and about 200,000 records covering upcoming travel in 2026 with dates, times and booking references tied to names.</p><p>That last category is the one that actually worries me. Email addresses leak and life goes on, sadly it's a daily thing now. "This named person, with this car registration, is flying on this date" is a different kind of breach, though, and to their limited credit FulcrumSec appeared to recognise it, saying they might redact forward travel records.</p><p>But going back to that previous point, something didn't sit right with me. I use custom email addresses per service, and the MAG breach was no different: <code>mag@scotthelme.co.uk</code></p><p>How on Earth did they enumerate that?</p><p></p><h2 id="it-wasnt-email-enumeration">It wasn't email enumeration</h2><p>That question was nagging at me, so I dug deeper. The call in MAG's code looks up one person at a time:</p><pre><code>GET /api/users/{email}
</code></pre><p></p><p>So how do you get to 8.8 million records? You'd need the email addresses first, and you can't guess them all. I know I can't be the only person using service-specific aliases like I do.</p><p>Turns out it's easy, you don't enumerate. You export!</p><pre><code>GET /api/export/data.csv?dataTypeName=user&range=All
</code></pre><p></p><p><code>dataTypeName</code> is the only required parameter. One request, the entire user table. There's a JSON variant, and a list-walking route via <code>/api/lists</code> into <code>/api/lists/getUsers</code>, which returns every address on a list without pagination.</p><p><code>dataTypeName</code> accepts 51 values. Among them:</p><p></p><table>
<thead>
<tr>
<th>Value</th>
<th>What it returns</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>user</code></td>
<td>every customer profile</td>
</tr>
<tr>
<td><code>purchase</code></td>
<td>transaction history</td>
</tr>
<tr>
<td><code>customEvent</code></td>
<td>whatever MAG defined — bookings, parking</td>
</tr>
<tr>
<td><code>emailSend</code></td>
<td>every message sent to you</td>
</tr>
<tr>
<td><code>emailOpen</code> / <code>emailClick</code></td>
<td><strong>IP address and user agent, per open</strong></td>
</tr>
</tbody>
</table>
<p></p><p>That last row explains something that puzzled me in the <a href="https://haveibeenpwned.com/Breach/ManchesterAirportsGroup?utm_source=scotthelme.co.uk" rel="noreferrer">Have I Been Pwned entry for this breach</a>: alongside names and vehicle registrations, the compromised data classes include IP addresses and browser user agent strings. Those didn't come from the website. They came from tracking pixels in marketing emails, exported by data type.</p><p></p><h2 id="and-it-could-write">And it could write</h2><p>The same global auth covers the destructive half of the API too:</p><pre><code>DELETE /api/users/{email} delete a customer
DELETE /api/lists/{listId} delete a list
POST /api/users/forget GDPR erasure
POST /api/users/bulkUpdate mass-rewrite profiles
POST /api/users/updateEmail change someone's address
</code></pre><p></p><p>There's no indication FulcrumSec did any of this, and I'm glad, but they could have just nuked everything from orbit and MAG would have been really screwed. For four whole years, the capability to delete Manchester Airports Group's database was a <code>view-source</code> away. This wasn't only a confidentiality exposure. It was a colossal integrity and availability exposure too, and MAG just got lucky. How do they now trust any of the data that remains in the database? </p><p></p><h2 id="how-can-i-be-sure">How can I be sure?</h2><p>Because looking at the published data, my own records gave it away. Buried in a few hundred fields of parking bookings and Fast Track purchases were these:</p><pre><code class="language-json">"itblInternal.regionCode": "GB",
"itblInternal.isAnonymousUser": false,
"itblInternal.isUnknownUser": false,
"itblInternal.phoneType": "MOBILE",
"itblInternal.phoneCountry": "GB",
"itblInternal.emailDomain": "scotthelme.co.uk",
"itblDS.brandAffinityLabel": "negative",
"signupSource": "UpdateSubscriptionsAPI"
</code></pre><p></p><p><code>itbl</code> is Iterable's own system namespace. Every one of those values is something Iterable derives rather than something a customer supplies, my phone number parsed into a type and a country, the domain split off my address, a region code inferred. <code>itblDS</code> is Iterable Data Science, and Brand Affinity is a machine-learning score their platform generates. Apparently mine is negative, which feels fair.</p><p>Nothing in a car park booking produces a machine-learning affinity score. That field was written by Iterable, which means this record passed through Iterable. Whatever was taken carried Iterable's derived fields, Iterable's list and channel IDs, and Iterable's export flattening. And the group who took it said they used Iterable credentials from client-side JavaScript, which is where three of them had been sitting for four years, and which MAG revoked in the days before disclosing.</p><p><code>signupSource</code> names the API method that created the record: <code>POST /api/users/updateSubscriptions</code>.</p><p>The record also carries Iterable's own subscription state, <code>emailListIds</code>, <code>unsubscribedChannelIds</code>, <code>subscribedMessageTypeIds</code>, which appear in exactly two places: the response from <code>GET /api/users/{email}</code>, and the output of the user export.</p><p>Even the shape is a tell. Every nested value appears twice, once as an object and once flattened into a dotted key:</p><pre><code class="language-json">"cip.lastBooking.parking.vehicleReg": "HE23LME",
"cip.lastBooking.parking": { "vehicleReg": "HE23LME", ... }
</code></pre><p></p><p>That's what Iterable's export produces when it flattens <code>dataFields</code> into columns while keeping the original object. So the data left Iterable. Now, which type of key?</p><p>Iterable publish their full OpenAPI specification, unauthenticated, at <code>https://api.iterable.com/api-docs</code>. The <code>security</code> block is global, every endpoint takes the same <code>Api-Key</code> header. But each endpoint is also tagged with the key types permitted to call it, in an extension called <code>x-iterable-api-key-types</code>:</p><pre><code class="language-text">POST /api/events/track Server-side, JavaScript, Mobile, Web
POST /api/users/update Server-side, JavaScript, Mobile, Web
GET /api/users/{email} Server-side
GET /api/export/data.csv Server-side
GET /api/lists/getUsers Server-side
</code></pre><p></p><p>Of 131 endpoints, 98 are server-side only. Twelve accept a browser key.</p><p>At first, I thought the code proves the key class: <code>GET /api/users/{email}</code> is server-side only, MAG's page called it, therefore server-side key, right? But look at that call again, it ends in <code>.catch(e => { console.warn(e) })</code>, and the <code>IterableUID</code> it writes is read by nothing. With a browser-scoped key the event tracking would still have worked, the profile lookup would have 401'd into a swallowed promise, and the site would have continued. It could have been failing silently since 2022 and nobody would have known.</p><p>The export is what settles it. Producing that record requires the user export or the list endpoints, and both are server-side only. So a server-side key was used. The only credentials in play were three server-side keys published in MAG's own JavaScript, the ones FulcrumSec named as their route in, and the ones MAG revoked, all three, in the days before going public.</p><p>Iterable's fingerprints are on the data, and Iterable's spec says what key type was required to access that data. I'm confident that, on balance, this is most likely how the data was stolen.</p><p></p><h2 id="so-what-was-this-api-key-actually-used-for">So what was this API key actually used for?</h2><p>I went looking for the code that needed this key, expecting something substantial. It's 1,119 bytes, lazy-loaded from <code>/_next/static/chunks/95707.a17f18ff1a88c71f.js</code>:</p><pre><code class="language-js">useEffect(() => {
const email = new URLSearchParams(location.search).get("email");
const userId = sessionStorage.getItem("IterableUID")
|| new URLSearchParams(location.search).get("userId");
const payload = {
eventName: props.eventName || "pageView",
createdAt: Date.now(),
dataFields: { pageUrl: props.pageUrl || location.href.split("?")[0] }
};
if (email) payload.email = email;
if (userId) payload.userId = userId;
if (props.templateId) payload.templateId = props.templateId;
if (props.campaignId) payload.campaignId = props.campaignId;
if (email || userId) trackEvent(payload); // POST /api/events/track
if (email) lookupUser(email); // GET /api/users/{email}
}, []);</code></pre><p></p><p>That's it. That's the whole reason. </p><p>Iterable's marketing emails link back to the site with the recipient's address sitting in the query string, <code>?email=you@example.com&userId=...</code>. This component reads it, fires a <code>pageView</code> event back to Iterable, and the campaign gets credit for your visit. Attribution. That's the job. </p><p>And the second call, the dangerous one, <code>GET /api/users/{email}</code>, the arbitrary profile read? It writes the result into <code>sessionStorage</code> under <code>IterableUID</code>, and the same component reads it back on later page views in the session, so subsequent visits still carry an identifier.</p><p>So a credential with access to all but ten of 131 endpoints, including a full database export and the ability to delete customers, was published to every visitor of three airport websites for over four years, so that a marketing email could get attribution credit for a page view. And half of what it did was populate a cache that nothing reads.</p><p></p><h2 id="this-is-not-a-mag-problem">This is not a MAG problem</h2><p>It would be easy to file this under "airport group made silly mistake", but it isn't that.</p><p>The pattern is: a marketing or analytics platform gives you an API key, the integration guide shows you a <code>fetch</code> call, and it works. It works in dev, it works in staging, it works in prod. Nothing fails. No error appears. No test goes red. The application behaves perfectly while a credential with server-side authority sits in a file that every visitor downloads.</p><p>And look at what the browser was actually doing with it. I searched every chunk the site loads, and every archived bundle back to 2022, for any sign of the valuable data being sent from the page including vehicle registrations, booking references, travel dates. Nothing. Not one field, ever. The only thing the browser ever wrote to Iterable was this:</p><pre><code class="language-js">{ email, eventName, dataFields: { phoneNumber, airportCode } }
</code></pre><p></p><p>A newsletter signup. Your email, your phone number, and which airport you're a customer of.</p><p>Everything that made this breach worth 640GB, the parking history, the registrations, and the forward travel, was put into Iterable by a server-side sync from MAG's booking platform. That integration was fine. It was never exposed.</p><p>So the cause of this whole issue is clear, and it's worse than "they leaked a key": email click tracking was given a credential scoped to the entire customer database,<strong> in order to record a click.</strong> The read authority and the write requirement were four orders of magnitude apart, and nothing in the toolchain notices a mismatch like that.</p><p>And there is no natural moment at which anyone notices. Secret scanning runs against your repository, and this key probably <em>was</em> in a config file or a CI variable, which is exactly where it's supposed to be. The leak happens at build time, when it gets baked into a bundle. A pen test scopes your login and your booking flow, not the twelfth webpack chunk. A WAF sees nothing, because there's no malicious request to your origin; the traffic goes straight from the victim's browser to <code>api.iterable.com</code> and never touches your infrastructure at all.</p><p>Which is the part I find most uncomfortable. This vector never touched MAG's servers. None of it would appear in a log MAG owns. The entire incident happened in other people's browsers and on somebody else's API, using a credential they published themselves.</p><p>The uncomfortable detail is that this one was avoidable by reading the manual. Iterable's documentation carries it in a box marked WARNING:</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/360043464871-API-Keys.png" class="kg-image" alt="No Hacking Required: The Manchester Airports Group Data Breach" loading="lazy" width="706" height="179" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/09/360043464871-API-Keys.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/360043464871-API-Keys.png 706w"><figcaption><a href="https://support.iterable.com/hc/en-us/articles/360043464871-API-Keys?utm_source=scotthelme.co.uk"><span style="white-space: pre-wrap;">source</span></a></figcaption></figure><p></p><blockquote>"Never embed a server-side API key in client-side code (whether JavaScript, a mobile application, or otherwise), since they can be used to access project data. Use these server-side API keys only when making API calls from your servers."</blockquote><p></p><p>They issue client-side key types that reach only the handful of endpoints front-end code actually needs, and JWT authentication is on by default when you create one. It's been mandatory for new organisations since 30 August 2022.</p><p>The keys in these bundles landed in June and July 2022. They missed that cutoff by weeks, and then nobody looked again for four years.</p><p>I've spent years arguing that the browser is a part of your attack surface that most organisations just don't monitor. This is that argument with 8.8 million records attached to it.</p><p></p><h2 id="what-to-actually-do-about-it">What to actually do about it</h2><p>There are a few important steps you can take right now, and considerations you take forwards:</p><p></p><ol><li><strong>Grep your own bundles today.</strong> Not your repo, your built, deployed JavaScript. Pull every script your production pages load and search for <code>api_key</code>, <code>apiKey</code>, <code>Api-Key</code>, <code>token</code>, <code>secret</code>, and 32-character hex strings. This takes an afternoon and it is the highest-value hour of work in this entire post.</li><li><strong>Assume anything ever shipped to a browser is public forever.</strong> Rotating the key fixes the future. It does not remove it from the Internet Archive, from someone's <code>curl</code> history, or from a threat actor's notes. Every one of these three keys is still sitting in a public archive right now.</li><li><strong>Move the call server-side.</strong> If your browser needs data from Iterable, Klaviyo, Braze or anything like them, it should ask <em>your</em> backend, which holds the credential and enforces who can ask for what. That's a small proxy endpoint, not an architecture project.</li><li><strong>Use the key type the vendor built for browsers.</strong> Iterable issue five, and the client-side ones reach a handful of endpoints instead of all of them. A leaked browser key can act as one person. A leaked server key can export everyone. Same header, same integration effort, completely different blast radius.</li><li><strong>Monitor what your pages actually load and where they send data.</strong> You cannot review ninety content-hashed chunks by hand every deploy. Something has to watch the scripts on your pages and the destinations they talk to, and tell you when either changes.</li></ol><p></p><p>That last one is the reason I do what I do, so yes I'm a little biased, but it is <strong>exactly</strong> what <a href="https://report-uri.com/?utm_source=scotthelme.co.uk" rel="noreferrer">Report URI</a> was built to do.</p><p></p><h2 id="the-uncomfortable-truth">The uncomfortable truth</h2><p>While confirming the Iterable key is gone, I looked at the rest of the same configuration object that still ships to browsers today.</p><p>It is not empty. There are several other credential-shaped values in it, including one paired with an AWS API Gateway endpoint.</p><p>I'm not publishing those, but they're as public as the others, and I'm not sure a couple of those should be public. But it does illustrate the point better than anything else I could write: the Iterable key was removed because an attacker forced the issue and 8.8 million people paid for it. The ones sitting next to it are still there, so, has anybody looked?</p><p></p><h2 id="want-the-people-who-did-this-on-your-team">Want the people who did this on your team?</h2><p>Everything in this post came from reading JavaScript that three different airports published to the world. No exploit, no access, no privileged information. A key sitting in the seventeenth of ninety chunk files, and four years of archived copies to date it against.</p><p>That's the uncomfortable bit, this was discoverable the entire time. It was discoverable in 2022, and in 2023, and on every single deploy until August 2026. Nobody looked, because looking means reading ninety content-hashed bundles after every release, and nobody really does that.</p><p>So by all means, go and grep your bundles. You should. But understand what a grep gives you: a snapshot of today. Run it in May 2022 against Manchester Airport and you'd have found nothing at all, the key landed in July 2022. The exposure isn't a state you can check once. It's a thing that arrives on a Tuesday in a chunk nobody reviewed, and then stays for four years.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?utm_source=scotthelme.co.uk"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/report-uri-logo.png" class="kg-image" alt="No Hacking Required: The Manchester Airports Group Data Breach" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/09/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><p>That's the problem <a href="https://report-uri.com/?utm_source=scotthelme.co.uk" rel="noreferrer">Report URI</a> was built for. Not reporting on it after someone else tells you, watching the scripts your pages actually load, and the destinations they actually send data to, and telling you the moment either one changes. If MAG had been watching, the question wouldn't have been "did anyone take the key". It would have been "why is our booking site talking to <code>api.iterable.com</code> from the browser?", and it would have been asked in July 2022, not by an extortion group in August 2026.</p><p>Your website is running code right now. Do you know what code it's running?</p><p><a href="https://report-uri.com/register?plan=business2025&ref=scotthelme.co.uk"><strong>Start your free trial, no credit card required →</strong></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[The ultimate road trip combo: Starlink Mini + UniFi Travel Router]]></title><description><![CDATA[<p>I recently went on an epic road trip around Europe, covering 1,645 miles (2,647 km), and we took in some amazing sights and locations. As a tech geek, I was worried about my connectivity on the trip, so before we set off, I came up with a plan!</p>]]></description><link>https://scotthelme.co.uk/the-ultimate-road-trip-combo-starlink-mini-unifi-travel-router/</link><guid isPermaLink="false">6a8c17b0f10eb90001ef02a9</guid><category><![CDATA[Ubiquiti]]></category><category><![CDATA[UniFi]]></category><category><![CDATA[Travel Router]]></category><category><![CDATA[Starlink]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 24 Aug 2026 13:50:51 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/porsche-road-trip.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/porsche-road-trip.png" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router"><p>I recently went on an epic road trip around Europe, covering 1,645 miles (2,647 km), and we took in some amazing sights and locations. As a tech geek, I was worried about my connectivity on the trip, so before we set off, I came up with a plan!</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/08/image-2.png" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="768" height="1038" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/image-2.png 768w" sizes="(min-width: 720px) 720px"></figure><p></p><h2 id="did-we-really-need-this">Did we really need this?</h2><p>On a more serious note, we all like to have connectivity, but I am also responsible for running my own company, <a href="https://report-uri.com/?utm_source=scotthelme.co.uk" rel="noreferrer">Report URI</a>, which has other staff members. I wanted to make sure that I had at least somewhat reliable connectivity whilst I was away as, anyone running their own company can confirm, you're never really 'not working' when everything falls to you.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?utm_source=scotthelme.co.uk"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/report-uri-logo-1.png" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><p>Of course we could get an eSIM for my phone, and then I could tether, but my wife would also need connectivity, so that's another eSIM, and then on the longer stints in the car it'd be nice to give my son some connectivity too... It was quickly looking like it wouldn't be practical, and there were also concerns about the times we wouldn't have any cellular connectivity too.</p><p></p><h2 id="the-two-devices-we-bought-for-the-trip">The two devices we bought for the trip</h2><p>Being quite the fan of <a href="https://scotthelme.co.uk/tag/ubiquiti/?utm_source=scotthelme.co.uk" rel="noreferrer">Ubiquiti kit</a>, I wanted to try out the <a href="https://uk.store.ui.com/uk/en/products/utr?utm_source=scotthelme.co.uk" rel="noreferrer">Unifi Travel Router</a>. This is a tiny device that can connect to an external Wi-Fi network and then broadcast your usual home Wi-Fi networks for all of your devices to connect to. Super handy because all of my devices then just connect to my home network without any configuring or joining the hotel Wi-Fi, and, the UTR can VPN me home so all of my usual devices and services are available if I want them.</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/08/Screenshot-2026-08-24-111730.png" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="469" height="587"></figure><p></p><p>The other bit of kit I purchased was a Starlink Mini. I got mine from a <a href="https://www.currys.co.uk/products/starlink-mini-satellite-antenna-and-wifi-router-kit-dualband-10269577.html?utm_source=scotthelme.co.uk" rel="noreferrer">local electronics retailer</a> that had a deal on, and I'm sure you can find a suitable retailer in your area. I also bought a carry case from Amazon along with some accessories to mount it to the panoramic glass roof in the Porsche, and a 12V 'cigarette' socket adapter that could provide the higher power the Starlink needs from the car. Note: the USB-C ports in your car might not provide enough power for the Starlink, mine didn't, hence the 12V adapter.</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/08/IMG_9898.jpeg" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="1645" height="2193" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/IMG_9898.jpeg 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/IMG_9898.jpeg 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/08/IMG_9898.jpeg 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/IMG_9898.jpeg 1645w" 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/08/IMG_9897.jpeg" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="2000" height="2667" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/IMG_9897.jpeg 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/IMG_9897.jpeg 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/08/IMG_9897.jpeg 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/08/IMG_9897.jpeg 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/08/IMG_9472.jpeg" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="2000" height="2667" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/IMG_9472.jpeg 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/IMG_9472.jpeg 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/08/IMG_9472.jpeg 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/08/IMG_9472.jpeg 2400w" sizes="(min-width: 720px) 720px"></figure><p></p><p>It's quite a nice setup, out of the way, and it automatically powers up when the car turns on with no effort from us. There's also a nice feature of being able to leave the 12V power source in the boot (trunk, for the Americans) turned on for a period of time after you leave the car and lock it, so we can keep connectivity in the general vicinity before it powers down. </p><p></p><h2 id="how-well-did-the-unifi-travel-router-work">How well did the UniFi Travel Router work?</h2><p>We used the UTR on our first day of the trip as we drove a few hundred miles to the south coast of England to take the ferry to Spain on the Portsmouth to Santander route. The Wi-Fi on the ferry is not free, and I didn't fancy paying for a whole bunch of devices either. Instead, I could buy a Wi-Fi pass, join the UTR to the ferry Wi-Fi and then the UTR will broadcast our usual Wi-Fi network for all of our devices! I had Wi-Fi on my phone, my laptop, my wife had it on her devices and my son had it on his. This saving alone paid for ~50% of the cost of the UTR itself! We didn't have a balcony or any external area we could place the Starlink, and the window in our room was tiny, but the UTR worked out great for the two nights on the ferry. It was a similar story in every hotel and Airbnb we stayed in for the whole trip. Rather than arrive at a hotel and have to go through the Wi-Fi joining process on a whole bunch of devices, I would just power up the UTR, join it to the Wi-Fi, and all of our devices would automatically connect to our normal home Wi-Fi. This was a very nice convenience feature.</p><p>One unexpected benefit of the UTR came in a hotel with poor Wi-Fi signal. At the end of the room with the seats and desk, there was a single bar of signal, nothing worked and it dropped regularly. By the door to the hallway, signal was great, but you can't sit there with your laptop and do emails! I took a phone charger to power the USB-C UTR, plugged it in by the door and it had a good signal. It then broadcast our usual Wi-Fi network into the room and everyone had great Wi-Fi, problem solved!</p><p>Overall, I'd say the UTR is definitely worth it. I didn't use the VPN feature much, it wasn't what I needed or wanted from it, but I did test it out and it worked perfectly. The convenience of joining a single device to an external Wi-Fi network and then having all of your devices automatically connect is really nice, and the portability of it allows you to move it around for the best signal strength. If you're having to pay for Wi-Fi somewhere, it's a no-brainer. (The UTR can also do Ethernet backhaul, but I never needed to try that out)</p><p></p><h2 id="how-well-did-the-starlink-mini-work">How well did the Starlink Mini work?</h2><p>We set everything up with the Starlink Mini before we left home so as soon as we hit the road, we were on the built-in Wi-Fi network on all of our devices. In many ways, that's all I have to say about the Starlink... We plugged it in, set it up, mounted it, and that's it. It just worked. Every single day, every single time. The damn thing is annoyingly flawless.</p><p>I'm not one for hype or fanfare, but I feel like Starlink will be a transformational technology. It doesn't matter where you are, as long as you can see the sky, you're connected. On some of the mountain roads and coastal roads we took on the trip, we had no phone service at all. Genuinely zero. We were still fully connected thanks to the Starlink. Even with the multiple speed tests we did at various locations, we were always seeing 100Mbps+, and we even did some high speed tests at ̶2̶0̶0̶k̶m̶/h̶ 140km/h. It turns out how fast you're moving is kind of irrelevant because the satellite you're connected to is going at 27,600 km/h, so you're basically not moving as far as it's concerned.</p><p>The only thing I had to make a change for was that I normally do wireless Apple CarPlay, but I had to switch to wired Apple CarPlay so my phone could connect to the Starlink Wi-Fi network for data instead of the Porsche Wi-Fi for CarPlay. That's it. It was awesome to be able to have stable voice calls, stable video calls, large attachments are no issue, you just use your devices like normal without any consideration for data or connectivity.</p><p></p><h2 id="theyre-the-ultimate-combo">They're the ultimate combo</h2><p>You might not be planning your own road trip, and I can definitely see how each of these devices has its own benefits that would justify buying either of them in isolation. The UTR isn't much bigger than a credit card and ~1cm thick, but provides huge value and benefits. The Starlink is a different beast in the physical dimensions, but it's now going to form part of our regular travel kit. Since the European road trip, we've already used it on two more trips including a long weekend in a remote Scottish location, and during a race weekend in the paddock when we're uploading and downloading huge video and telemetry files and the Wi-Fi at the race track just can't provide reliable or high-speed connectivity.</p><p>Hopefully that gives a nice summary of the benefits of these two devices, but if you want it in a single sentence for each:</p><p>The Starlink will give you connectivity where you otherwise have none.</p><p>The UTR will make whatever connectivity you have behave like you're at home.</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/08/IMG_9799.jpeg" class="kg-image" alt="The ultimate road trip combo: Starlink Mini + UniFi Travel Router" loading="lazy" width="2000" height="2667" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/IMG_9799.jpeg 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/IMG_9799.jpeg 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/08/IMG_9799.jpeg 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/08/IMG_9799.jpeg 2400w" sizes="(min-width: 720px) 720px"></figure>]]></content:encoded></item><item><title><![CDATA[Introducing dbsc.dev: Does Your Browser Support DBSC?]]></title><description><![CDATA[<p>Every other web platform feature I've ever written about, I've been able to test in some easy way. Open DevTools, type the name of the thing, see if it's there. Device Bound Session Credentials doesn't work like that: there is no way</p>]]></description><link>https://scotthelme.co.uk/introducing-dbsc-dev-does-your-browser-support-dbsc/</link><guid isPermaLink="false">6a87306bf001a000018b8f0a</guid><category><![CDATA[DBSC]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Fri, 21 Aug 2026 13:42:10 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-dev.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-dev.png" alt="Introducing dbsc.dev: Does Your Browser Support DBSC?"><p>Every other web platform feature I've ever written about, I've been able to test in some easy way. Open DevTools, type the name of the thing, see if it's there. Device Bound Session Credentials doesn't work like that: there is no way for a page to ask the browser whether it supports DBSC. None! So I built <a href="https://dbsc.dev/?utm_source=scotthelme.co.uk">dbsc.dev</a>, which answers the question the only way I could think to answer it.</p><p></p><figure class="kg-card kg-image-card"><a href="https://dbsc.dev/?utm_source=scotthelme.co.uk"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/image-1.png" class="kg-image" alt="Introducing dbsc.dev: Does Your Browser Support DBSC?" loading="lazy" width="1428" height="898" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/image-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/image-1.png 1428w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="a-feature-you-cannot-feature-detect">A Feature You Cannot Feature-Detect</h3><p>Here's the thing that makes DBSC unusual. Almost everything we bolt onto the web platform comes with a hook you can poke at from JavaScript.</p><pre><code class="language-js">if (window.PublicKeyCredential) { /* passkeys are available */ }
if ('serviceWorker' in navigator) { /* ... */ }
</code></pre><p></p><p>DBSC has nothing. No <code>navigator.deviceBoundSessions</code>, no constructor, no promise that rejects. The entire protocol lives in HTTP headers, and it's driven by the server, not the page. Your server says "I'd like to bind this session to a device key" and the browser either quietly gets on with it, or it quietly ignores you.</p><p>That design is deliberate, and it's a good thing. It's what makes DBSC safe to deploy: a browser that doesn't support it ignores the registration header and carries on with normal cookie auth, so you cannot lock anyone out by switching it on. I've made that point before and I stand by it. But it does leave you with an awkward question if you're on the other side of it, wondering whether your own browser is doing any of this.</p><p>You can't ask. You can only find out by trying...</p><p></p><h3 id="watching-for-the-answer">Watching For The Answer</h3><p>So that's what the site does. When you load <a href="https://dbsc.dev/?utm_source=scotthelme.co.uk" rel="noreferrer">dbsc.dev</a>, the response carries a registration header, exactly as a real deployment would:</p><pre><code class="language-http">HTTP/2 200
Cache-Control: no-store
Secure-Session-Registration: (ES256); path="/dbsc/register"; challenge="519aaae6ee95..."
</code></pre><p></p><p>If your browser supports DBSC, it generates a key pair, signs that challenge with the private half, and POSTs the result back to <code>/dbsc/register</code> without any involvement from the page at all. On my machine that round trip takes about 700ms. Meanwhile, the page is polling a status endpoint, waiting to see whether the registration lands.</p><p>If it lands, you get a green box. If nothing arrives within eight seconds, you get a red one — and I've been careful about what that red box says. It says <em>no registration attempt arrived in eight seconds</em>. It does not say your browser can't do DBSC, because that's not something this test can prove. Maybe an extension stripped the header. Maybe an enterprise policy turned it off. Maybe you're on a platform with no key store to bind to. The site lists all of that rather than claiming certainty it doesn't have.</p><p></p><h3 id="the-bit-i-actually-wanted-to-build">The Bit I Actually Wanted To Build</h3><p>A yes/no answer is fine, but it isn't very interesting, and honestly you can usually guess. What I wanted was to see the protocol in action and make it useful for debugging.</p><p>So once your browser registers, the page shows you the whole exchange. The registration JWT your browser sent, decoded. The device public key it generated, as a JWK, with its thumbprint and its PEM. The session instructions the server sent back. A plain <code>fetch()</code> proving the bound cookie is riding your ordinary requests, which is the entire security property in one line.</p><p>One detail I enjoyed: the device key doesn't travel where you might expect. The payload of that registration JWT is almost empty.</p><pre><code class="language-json">{
"jti": "mvt-nE8miIcX--li4LlmEo0bcs5m3BhqJoS2aob76Pc"
}
</code></pre><p></p><p>Just the challenge, signed. The key itself is up in the JWT <em>header</em>, as a <code>jwk</code> alongside <code>"typ": "dbsc+jwt"</code>. I had it wrong in my own code until I decoded a real one from Chrome.</p><p>While I'm being precise about what the site can and can't tell you: it cannot tell you your key is in a TPM. DBSC carries no attestation, so a server sees a public key and a valid signature and that's it. Anyone claiming otherwise from server-side data alone is guessing.</p><p></p><h3 id="watching-a-refresh-happen-live">Watching A Refresh Happen Live</h3><p>The refresh is the part nobody ever sees, because in production it happens to a cookie you were never looking at. It's also the part that makes DBSC work: the bound cookie is short-lived, and renewing it needs a fresh signature from the device key. Both the cookie and the challenge have to change every single time.</p><p>I wanted people to be able to watch that, so I set the bound cookie's lifetime to three minutes. Keep the tab open and you can see your device re-sign, with the old and new cookie values shown side by side. I couldn't go shorter than three minutes as I kept hitting TPM signing quota limits, so apologies for the short wait.</p><p></p><h3 id="why-you-might-actually-use-it">Why You Might Actually Use It</h3><p>If you're curious, it answers the question of whether or not your browser supports DBSC in just a few seconds, and then shows you the protocol if you'd like to know more.</p><p>If you're implementing DBSC, it's a reference exchange you can point at. Here's what a real registration JWT looks like. Here's the two-phase refresh — the 403 with the challenge, then the signed retry. Here's what rotation looks like when it's working. I wrote the server side of this twice before I got the wire protocol right, and a working example would have saved me a fortnight.</p><p>If you're filing a bug, there's a copy button that gives you the whole diagnostic as text: the verdict, the decoded JWTs, the key, the timeline, and what your browser told us about itself. Rather more useful in an issue than "DBSC doesn't work for me".</p><p></p><h3 id="on-privacy">On Privacy</h3><p>Nothing is retained. Each visit mints a throwaway identifier, and the key material and timeline are deleted within the hour. No IP logging, no analytics on your session, nothing shared. The key you see on the page is a public key your own browser generated for that one page load, and it's worthless to me.</p><p>I wanted to be clear on this, given the site's entire subject is device-bound cryptography.</p><p></p><h3 id="under-the-hood">Under The Hood</h3><p>The server side is <a href="https://github.com/report-uri/dbsc-php?utm_source=scotthelme.co.uk">dbsc-php</a>, the open source library I extracted from Report URI's production DBSC integration. The site is a couple of hundred lines of PHP on top of it. If you want to run DBSC on your own site and you're in the PHP world, that library carries all the wire-protocol corrections that only surface when you integrate against a real browser — including several <a href="https://scotthelme.co.uk/everything-i-learned-shipping-device-bound-session-credentials/?utm_source=scotthelme.co.uk" rel="noreferrer">I learned about the hard way</a> and wrote up separately.</p><p>The site is sponsored by <a href="https://report-uri.com/?utm_source=scotthelme.co.uk">Report URI</a>, same as <a href="https://whynopasskeys.com/?utm_source=scotthelme.co.uk">Why No Passkeys?</a>.</p><p></p><h3 id="client-support">Client Support</h3><p>Chrome remains the only browser that implements DBSC. It's generally available on Windows, and <a href="https://scotthelme.co.uk/device-bound-session-credentials-lands-in-chrome-on-macos/?utm_source=scotthelme.co.uk" rel="noreferrer">support has since landed on macO</a>S. Firefox and Safari have nothing. That'll date quickly, which is rather the point of building a site that tests instead of a table that claims.</p><p>Go and find out: <a href="https://dbsc.dev/?utm_source=scotthelme.co.uk"><strong>dbsc.dev</strong></a></p><p></p><h3 id="sources">Sources</h3><ul><li><a href="https://w3c.github.io/webappsec-dbsc/?utm_source=scotthelme.co.uk">Device Bound Session Credentials</a> — W3C specification</li><li><a href="https://developer.chrome.com/docs/web-platform/device-bound-session-credentials?utm_source=scotthelme.co.uk">Device Bound Session Credentials</a> — Chrome for Developers</li><li><a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?utm_source=scotthelme.co.uk">Device Bound Session Credentials: making stolen cookies useless</a> — my earlier explainer</li><li><a href="https://github.com/report-uri/dbsc-php?utm_source=scotthelme.co.uk">report-uri/dbsc-php</a> — the server library</li><li><a href="https://scotthelme.co.uk/everything-i-learned-shipping-device-bound-session-credentials/?utm_source=scotthelme.co.uk" rel="noreferrer">Everything I Learned Shipping Device Bound Session Credentials</a> - my stories from the trenches</li></ul>]]></content:encoded></item><item><title><![CDATA[Device Bound Session Credentials lands in Chrome on macOS]]></title><description><![CDATA[<p><a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?utm_source=scotthelme.co.uk" rel="noreferrer">Device Bound Session Credentials</a> (DBSC) is Chrome's answer to session cookie theft, usually by InfoStealer malware. Instead of a cookie being a bearer token that works anywhere it's pasted, DBSC binds the session to a private key that lives in your device's hardware and</p>]]></description><link>https://scotthelme.co.uk/device-bound-session-credentials-lands-in-chrome-on-macos/</link><guid isPermaLink="false">6a7afcfd9f063f00018dac2d</guid><category><![CDATA[DBSC]]></category><category><![CDATA[macOS]]></category><category><![CDATA[chrome]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Tue, 11 Aug 2026 14:25:04 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-macos.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-macos.png" alt="Device Bound Session Credentials lands in Chrome on macOS"><p><a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?utm_source=scotthelme.co.uk" rel="noreferrer">Device Bound Session Credentials</a> (DBSC) is Chrome's answer to session cookie theft, usually by InfoStealer malware. Instead of a cookie being a bearer token that works anywhere it's pasted, DBSC binds the session to a private key that lives in your device's hardware and can never be exported. Chrome has to prove possession of that key to be issued fresh cookies, so a stolen cookie stops working almost immediately after it leaves the device.</p><p>That protection shipped to Windows first, backed by the TPM, and it's now on macOS, backed by the Secure Enclave.</p><p></p><h2 id="the-timeline">The timeline</h2><p>The Chrome Enterprise and Education release notes are the clearest official statement of platform availability:</p><blockquote><strong>Chrome 145 on Windows</strong>: Feature rolls out gradually.<br><strong>Chrome 147 on macOS</strong>: Feature rolls out gradually.</blockquote><p></p><p>Note "rolls out gradually" on both. This isn't a switch that flips for everyone the moment you update. It's a staged rollout controlled by Chrome's variations system (called <a href="https://developer.chrome.com/docs/web-platform/chrome-finch?utm_source=scotthelme.co.uk" rel="noreferrer">Finch</a>) so your browser may or may not have support for it just yet.</p><p>You can see that split in the Chromium source. In <code>net/base/features.cc</code>:</p><pre><code class="language-cpp">#if BUILDFLAG(IS_WIN)
BASE_FEATURE(kDeviceBoundSessions, base::FEATURE_ENABLED_BY_DEFAULT);
#else
BASE_FEATURE(kDeviceBoundSessions, base::FEATURE_DISABLED_BY_DEFAULT);
#endif
</code></pre><p>Windows gets DBSC by default. Every other platform, macOS included, is off by default and depends entirely on the variations seed to switch it on. If you're testing DBSC on a Mac and nothing happens like when I tested it, this is why.</p><p></p><h2 id="reading-your-variations-assignment">Reading your variations assignment</h2><p><code>chrome://version</code> lists your active variations, but as hashed pairs:</p><pre><code>4e5d86a8-5e85fe73
</code></pre><p></p><p>Those are the trial name and group name, each hashed. The algorithm is <code>base::HashFieldTrialName</code> in <code>base/metrics/metrics_hashes.cc</code>:</p><pre><code class="language-cpp">uint32_t HashFieldTrialName(std::string_view name) {
std::array<uint8_t, SHA_DIGEST_LENGTH> hash;
::SHA1(reinterpret_cast<const uint8_t*>(name.data()), name.size(), hash.data());
return U32FromLittleEndian(base::span(hash).first<4>());
}
</code></pre><p></p><p>SHA-1 of the name, take the first four bytes, read them as a <strong>little-endian</strong> uint32. The pair is then printed <code>%x-%x</code>, lowercase, no zero padding. Here's the Python to reproduce it:</p><pre><code class="language-python">import hashlib, struct
def hash_name(name):
digest = hashlib.sha1(name.encode()).digest()[:4]
return struct.unpack('<I', digest)[0]
trial = "DeviceBoundSessionCredentialsMac"
group = "Control_Post149Split_30pct"
print("%x-%x" % (hash_name(trial), hash_name(group)))
# 4e5d86a8-5e85fe73
</code></pre><p></p><p>Some useful values to search your own list for:</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Name</th>
<th>Hash</th>
</tr>
</thead>
<tbody>
<tr>
<td>Trial: <code>DeviceBoundSessionCredentialsMac</code></td>
<td><code>4e5d86a8</code></td>
</tr>
<tr>
<td>Group: <code>Enabled</code></td>
<td><code>3f4a17df</code></td>
</tr>
<tr>
<td>Group: <code>Disabled</code></td>
<td><code>3d47f4f4</code></td>
</tr>
<tr>
<td>Group: <code>Default</code></td>
<td><code>ca7d8d80</code></td>
</tr>
<tr>
<td>Group: <code>Control</code></td>
<td><code>f23d1dea</code></td>
</tr>
<tr>
<td>Group: <code>ClientSideFeatureConflict</code></td>
<td><code>bfe70100</code></td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<p></p><p>The hash is one-way, so you can only confirm names you can guess, or you can just use this URL and view them as readable text:</p><pre><code>chrome://version/?show-variations-cmd
</code></pre><p></p><p>That prints the full variations command line with readable trial and group names, in the form <code>TrialName/GroupName</code> so you can search for <code>DeviceBoundSessionCredentialsMac</code>.</p><p></p><h2 id="what-a-control-group-looks-like">What a control group looks like</h2><p>This is where it got interesting on my Mac. My assignment:</p><pre><code>DeviceBoundSessionCredentialsMac/Control_Post149Split_30pct
</code></pre><p></p><p>A control arm of a 30% split. Control groups exist so Google can measure the treatment against a baseline, and this one isn't passive. Scanning <code>--disable-features</code> in the same output, every one of these is tagged <code><DeviceBoundSessionCredentialsMac</code>, meaning the study is what switches them off:</p><pre><code>UseUnexportableKeyServiceInBrowserProcess
PersistDeviceBoundSessions
UnexportableKeyDeletion
DeviceBoundSessionsFederatedRegistration
DeviceBoundSessionsForRestrictedSites
EnableChromeRefreshTokenBinding
EnableOAuthMultiloginStandardCookiesBinding
EnableOAuthMultiloginStandardCookiesBindingForSecondaryPartitions
</code></pre><p></p><p><code>UseUnexportableKeyServiceInBrowserProcess</code> is the important one. It's the browser-process service that mints the hardware-backed key. With it disabled, Chrome will happily parse a <code>Secure-Session-Registration</code> header, discover it has no way to create a key, and abandon registration. No request to your registration endpoint, no console warning, not even an entry in a <code>chrome://net-export</code> capture. The server sees a perfectly good offer go out and nothing come back, and that's what threw me off when I was trying to test this.</p><p>That also explains why forcing the feature flag on didn't help. In the variations command line, a feature you set yourself appears bare, with no <code><TrialName</code> suffix, so I could confirm <code>DeviceBoundSessions</code> genuinely was enabled. The feature was on; the key service beneath it was off.</p><p>If you need to override a control assignment for testing, quit Chrome completely and launch it with both:</p><pre><code class="language-bash">open -a "Google Chrome" --args \
--enable-features=DeviceBoundSessions,UseUnexportableKeyServiceInBrowserProcess,PersistDeviceBoundSessions
</code></pre><p></p><p>Two gotchas worth knowing. <code>open --args</code> is ignored entirely if Chrome is already running, so quit it first. And Chrome's own "Relaunch" button rebuilds its command line from scratch, discarding anything you passed.</p><p></p><h2 id="where-this-leaves-us">Where this leaves us</h2><p>macOS support is here and it shipped in Chrome 147, but it's arriving gradually and a meaningful slice of users are in a control arm that switches the underlying key service off. If you're implementing DBSC and testing on a Mac, check <code>chrome://version/?show-variations-cmd</code> before you spend an evening debugging your implementation...</p><p>The good news for site operators is that none of this requires anything from you. DBSC degrades cleanly: a browser without support simply ignores the registration header, the binding is never created, and the session carries on as an ordinary cookie session. You can deploy it now and users pick up the protection as their browsers gain it. Over at <a href="https://report-uri.com/?utm_source=scotthelme.co.uk" rel="noreferrer">Report URI</a> in our <a href="https://scotthelme.co.uk/dbsc-beta-at-report-uri/?utm_source=scotthelme.co.uk" rel="noreferrer">beta deployment of DBSC</a>, we've now increased our sample to 10% of users and things continue to go smoothly. As we gain more confidence, we'll keep increasing until 100% of users have DBSC available, and you can see if your current session has DBSC protection on the Settings page 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/08/image.png" class="kg-image" alt="Device Bound Session Credentials lands in Chrome on macOS" loading="lazy" width="922" height="413" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/image.png 922w" sizes="(min-width: 720px) 720px"></figure><p></p><p>The 'Bound' column shows if your session has DBSC protection enabled with more and more customers seeing this as time goes by.</p>]]></content:encoded></item><item><title><![CDATA[Everything I Learned Shipping Device Bound Session Credentials]]></title><description><![CDATA[<p>We shipped Device Bound Session Credentials at Report URI, open-sourced the server-side implementation, and then discovered a long list of things the specification doesn't prepare you for.</p><p>Some caused random logouts. One could deadlock a browser tab indefinitely. Two silently turned a device-bound session back</p>]]></description><link>https://scotthelme.co.uk/everything-i-learned-shipping-device-bound-session-credentials/</link><guid isPermaLink="false">6a6674cb3666810001632ff6</guid><category><![CDATA[DBSC]]></category><category><![CDATA[chrome]]></category><category><![CDATA[cookies]]></category><category><![CDATA[PHP]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 10 Aug 2026 16:06:11 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-lessons.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-lessons.png" alt="Everything I Learned Shipping Device Bound Session Credentials"><p>We shipped Device Bound Session Credentials at Report URI, open-sourced the server-side implementation, and then discovered a long list of things the specification doesn't prepare you for.</p><p>Some caused random logouts. One could deadlock a browser tab indefinitely. Two silently turned a device-bound session back into an ordinary bearer-token session while everything appeared to be working.</p><p>This post is the collection of those production lessons: the bugs, browser behaviours, race conditions and implementation traps I wish we'd known before we started.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?utm_source=scotthelme.co.uk"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/report-uri-logo.png" class="kg-image" alt="Everything I Learned Shipping Device Bound Session Credentials" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h2 id="what-dbsc-actually-does">What DBSC actually does</h2><p>Briefly, because I have a full <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?utm_source=scotthelme.co.uk" rel="noreferrer">explainer blog post on DBSC</a> that you should read:</p><p>Session cookies have one enormous weakness: they're bearer tokens. If malware on a user's machine reads the cookie out of the browser's storage and sends it to an attacker, the attacker is now that user. Every MFA prompt, every device check, every clever thing you did at login has already happened, and the cookie doesn't care. Infostealer malware has industrialised exactly this problem.</p><p>DBSC fixes it by binding the session to a private key the browser generates in hardware — a TPM, a secure enclave — and cannot export. Alongside your normal session cookie there's a second, short-lived cookie. When that cookie expires, the browser <em>defers</em> whatever request needed it, calls a refresh endpoint on your server, proves possession of the device key by signing a challenge, and gets a fresh cookie back. Then, the deferred request resumes.</p><p></p><h2 id="the-spec-reads-backwards">The spec reads backwards</h2><p>Reading the specification, the shape I came away with was: registration is a two-step negotiation, and refresh is a single request. Both our tracking issue and my implementation plan were based on that. Turns out, it's the other way round.</p><p><strong>Registration is single-phase.</strong> You attach a <code>Secure-Session-Registration</code> header to an authenticated response. Chrome generates a key, signs a JWT, and POSTs it to your registration endpoint. You verify it, create the binding, and reply <code>200</code> with the bound cookie. Done. One round trip.</p><p><strong>Refresh is two-phase.</strong> The browser POSTs to your refresh endpoint with no challenge, because it doesn't have one yet. You answer <code>403</code> with a <code>Secure-Session-Challenge</code> header. The browser signs <em>that</em> and POSTs again. Now you answer <code>200</code> with a fresh cookie. Two round trips, and the <code>403</code> is the normal, healthy, everyday path, not an error.</p><p>I know I'm not alone here, because months later the author of a Node DBSC implementation opened an issue on our repo and said, unprompted:</p><blockquote>the 403-then-200 refresh took me an embarrassing amount of time to figure out</blockquote><p>If you take one thing from this post, take the fact that a <code>403</code> on your refresh endpoint is what progress looks like on the way to success.</p><p></p><h2 id="dont-put-the-state-in-your-session-store">Don't put the state in your session store</h2><p>This one is worse, because it fails silently and it fails <em>closed-looking</em>.</p><p>When we first shipped, DBSC state lived where all our other session state lives: in the session, keyed off the session ID. Obvious choice, right? Every request already loads it, it expires when the session expires, the plumbing is free. Easy.</p><p>PHP sessions, and plenty of other session implementations, serialise the entire session as one blob and write the whole thing back. Last writer wins.</p><p>Now look at what the browser does immediately after login:</p><pre><code>POST /login → 200, response carries Secure-Session-Registration
├── GET /account/ (the navigation the user is actually doing)
└── POST /dbsc/register (Chrome, off the back of that header)
</code></pre><p></p><p>Those two run concurrently on the same session. <code>/account/</code> loads the session <em>before</em> registration completes, does its normal work, and writes its pre-registration snapshot back last. The binding we just carefully created got nuked in the process.</p><p>This isn't just a bug, either, it's a security bug. The enforcement gate, finding no binding, concludes there is no DBSC session here and falls back to plain cookie authentication. Which is exactly correct behaviour for a browser that doesn't support DBSC, but exactly wrong here. The user's session is now a bearer token again. No error was logged. Nothing looked broken. Our audit trail showed registrations succeeding, because they had.</p><p>The fix is to give DBSC its own storage, keyed by the session ID but written independently, so a concurrent session write can't clobber it. It's in the library's README as a warning now, phrased about as bluntly as I could manage:</p><blockquote>Report URI shipped DBSC with state in the PHP session blob; the post-login navigation races the <code>/dbsc/register</code> POST, both rewrite the whole blob last-writer-wins, the binding is clobbered, and enforcement silently no-ops — leaving exactly the stolen-cookie hole DBSC exists to close.</blockquote><p>If you're implementing this: your DBSC binding needs its own key. Not a field in an existing blob you rewrite wholesale.</p><p></p><h2 id="everything-rotates-or-the-browser-terminates-you">Everything rotates, or the browser terminates you</h2><p>Three related rules, all learned the same way, all now baked into the library.</p><p><strong>Rotate the cookie value on every refresh.</strong> If you verify the refresh JWT and reply <code>200</code> with the same cookie value the browser already has, because nothing has changed, so why not, Chrome reads that as "no refresh happened" and terminates the session. It wants to see rotation as proof the server actually did something I guess.</p><p><strong>Rotate the challenge on every refresh too.</strong> Same reasoning. A refresh that doesn't advance the challenge hasn't advanced anything.</p><p><strong>Do not put a challenge on the registration response.</strong> This one is properly counter-intuitive: it looks like an easy optimisation to hand the browser its first challenge on the same response that creates the session, saving that first <code>403</code>. Chrome reports a Challenge Error and the session never gets going.</p><p></p><p>The reason is buried in two separate sections of the spec, and I only really understood it a month later when I was arguing about test vectors with that Node implementer. A <code>Secure-Session-Challenge</code> carries an <code>id</code> parameter naming which session it belongs to, and a challenge whose session can't be identified is silently dropped. But the registration response is the response that <em>creates</em> the session. At the moment it's parsed, there's nothing for the <code>id</code> to name. So the challenge resolves to nothing and Chrome, I guess quite reasonably, complains.</p><p>I tried it, it didn't work, I reverted it, and there is now a test in the library whose name is literally <code>register response has NO Secure-Session-Challenge (Chrome rejects it there)</code>, because I did not want anyone (including future Scott with terrible memory) to "optimise" it back in.</p><p>There's a fourth rule in the same family that's less about Chrome and more about arithmetic: <strong>your challenge TTL must be longer than your cookie lifetime.</strong> The browser caches a challenge. If it caches one just before the cookie expires, and the challenge TTL is shorter, the challenge is dead by the time there's a reason to use it. Our library's config constructor now refuses to build with the values the wrong way round.</p><p></p><h2 id="the-bug-you-cannot-see-on-localhost">The bug you cannot see on localhost</h2><p>This is my favourite one, and it's the most transferable lesson here even if you never touch DBSC.</p><p>The bound cookie rotates on every refresh. Fine. But rotation is not instantaneous <em>from the browser's point of view</em>. The refresh is a round trip, and during that round trip the browser is still doing other things.</p><pre><code>t+0ms browser starts POST /dbsc/refresh
t+205ms browser dispatches GET /ajax/some-widget ← carries the OLD cookie. Correctly.
t+1235ms refresh response lands, browser stores the NEW cookie
</code></pre><p></p><p>That widget request left the browser 205ms into a 1,235ms refresh. It carried the pre-rotation cookie value because that was, at that instant, the only value the browser had. It is a completely legitimate request from a completely legitimate session.</p><p>Our enforcement gate compared the presented cookie against the stored one, found a mismatch, and concluded: stolen cookie. Terminate the session, revoke the binding, log the user out.</p><p>We shipped that and then beta users started getting randomly logged out.</p><p>The signature in our audit trail was a successful refresh followed about a second later by an enforcement termination, with <em>no</em> refresh failure between them, which is what told us the fault was in the gate, not the refresh path. We caught it properly with a request trace showing exactly the sequence above.</p><p>Here's the part that makes it dangerous: <strong>the failure rate is proportional to latency.</strong> The window is exactly the refresh round-trip time. On a developer's loopback interface that's a couple of milliseconds and you will basically never see it. We left it running on dev for two hours before it fired even once. Behind a CDN, over a real network, it's a second or more, and it fires on almost every refresh that races an ordinary request. Which is most of them, on a busy page.</p><p>It passed every test we have, but it broke in production because production has physics.</p><p>The fix is a single-depth history: accept the immediately-previous cookie value, but only until the instant that value would itself have expired in the browser anyway. I want to draw attention to that second clause, because it's a small design point I'm quite pleased with. There is no grace constant. No "give it five seconds and see". The acceptance window is exactly the lifetime the browser itself would still send that value for. It's a real quantity, derived from the system and it expires on its own without anyone having to tune it.</p><p>And the security exposure is genuinely bounded too. One generation deep, expiring naturally, and a genuinely stolen cookie still can't complete a refresh without the device key, so it still hard-fails within minutes.</p><p></p><h2 id="never-redirect-a-dbsc-endpoint">Never redirect a DBSC endpoint</h2><p>Now, the big one. Our DBSC endpoints inherited the standard authentication gate that sits in front of every authenticated route on our application. Sensible reuse. That gate does what every such gate does: if you're not authenticated, you get a redirect to the login page.</p><p>Consider what happens when a session finally expires while a tab sits idle:</p><ol><li>The tab wakes up and requests a page.</li><li>The bound cookie has expired, so Chrome <strong>defers</strong> that navigation and calls <code>/dbsc/refresh</code> first.</li><li>Our auth gate sees an expired session and answers the refresh with <code>302 → /login</code>.</li><li>Chrome... stops.</li></ol><p>Not "gives up". Not "terminates the session and continues". It deadlocks. The deferred navigation is never released, never times out, and never fails. Blank tab, preliminary headers, forever.</p><p>We proved it with a two-state matrix, forcing each state deliberately rather than waiting for a weekend to elapse:</p><p></p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>App session</th>
<th>DBSC binding</th>
<th><code>/dbsc/refresh</code> answers</th>
<th>Chrome</th>
</tr>
</thead>
<tbody>
<tr>
<td>expired</td>
<td>present</td>
<td><strong>302 → /login</strong></td>
<td><strong>deadlocks</strong> — deferred navigation never resumes</td>
</tr>
<tr>
<td>alive</td>
<td>deleted</td>
<td><strong>401</strong></td>
<td><strong>recovers</strong> — terminates the DBSC session, navigation continues to /login</td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<p></p><p>Same broken-session situation, same user experience intent, completely different outcome based purely on the status code. A <code>401</code> tells Chrome the session is over, it tears the DBSC session down, and the deferred navigation is released to do what it was always going to do and land on the login page. A <code>302</code> tells Chrome nothing it can act on, and it waits.</p><p>I feel like this is a bug so we raised it in Chromium as <a href="https://issues.chromium.org/issues/534027936?utm_source=scotthelme.co.uk" rel="noreferrer">534027936</a>.</p><p>Our fix is the bit I'd encourage you to copy. The obvious patch is to add a guard: <em>if this is a DBSC endpoint and the auth gate is about to redirect, send a 401 instead.</em> </p><p>The DBSC endpoints now override the redirect mechanism itself to answer <code>401</code>. Not "this gate doesn't redirect DBSC requests" but "<strong>this endpoint cannot emit a redirect at all</strong>". Every existing gate is covered, and, more importantly, so is every gate anyone adds in the next five years without knowing any of this.</p><p></p><h2 id="a-challenge-mismatch-is-not-an-attack">A challenge mismatch is not an attack</h2><p>The most important fix in the library didn't come from us. It came from a contributor in the Netherlands with production logs from his own Chrome 150 install, showing real users being logged out.</p><p>His trace, near enough:</p><pre><code>20:29:33 refresh succeeded, new challenge issued
── 16 minutes idle ──
20:45:40 refresh: challenge expired → session revoked path
20:45:41 refresh: challenge mismatch → session revoked path
20:45:41 enforcement terminated, user logged out mid-flow
</code></pre><p></p><p>His challenge TTL was 900 seconds. The challenge presented at 20:45:40 had been issued 967 seconds earlier. So the first refresh legitimately failed as expired, which is a benign, retriable outcome, and we handled it correctly by minting a fresh challenge and answering <code>403</code>.</p><p>One second later the browser came back. And it presented a challenge the server had <em>already rotated past</em>. That's a mismatch, and we treated a mismatch the way you'd expect: as a failed cryptographic proof. Stolen cookie. Revoke everything. Nuke it from orbit. Turns out though, that's wrong.</p><p>The signature is verified before the challenge is compared. The refresh handler verifies the JWT against the device-bound public key first. Only if that passes does it look at which challenge was signed. So <em>anything that reaches the mismatch branch has already proved possession of the device's private key.</em> It cannot be forgery as a forgery dies earlier, at the signature check, and still terminates the session exactly as it should.</p><p>This means a mismatch can only ever be one of a small set of benign situations:</p><ol><li><strong>Concurrent refreshes.</strong> Two requests fire on return from idle, a service worker fetch racing a main navigation perhaps, and both holding the same cached challenge. The first succeeds and rotates. The second is correctly signed and now stale.</li><li><strong>A lost response and a retry.</strong> The browser signed a challenge, the response never arrived, so it tries again. There is no client-side fix for this, it's just a reality of the network being unreliable.</li><li><strong>A challenge-delivery race of your own making</strong>, if like us you have more than one path that can hand the browser a challenge.</li></ol><p></p><p>So mismatch, along with missing and expired, is now a benign, retriable outcome. Mint a fresh challenge, answer <code>403</code>, let the browser try again. Bad signatures remain terminal and unchanged.</p><p>Another thing I liked was how this converges under concurrency, which the previous single-generation overlap window could not do. Three concurrent refreshes, all holding challenge <code>C</code>:</p><pre><code>A(C) → 200, cookie rotates, challenge now C2
B(C) → benign 403, mint C3 browser now signs C3
D(C) → benign 403, mint C4 browser now signs C4
B′(C3) → matches the previous value → 200, cookie rotates again
D′(C4) → C4 is now two generations back, overlap consumed → benign 403, mint C6
D″(C6) → 200. Converged.
</code></pre><p></p><p>Everybody gets there and nobody gets logged out. The cost is one extra round trip per concurrency event (which is a bargain compared to the cost of a support ticket). And note that this handles <em>unbounded</em> concurrency, whereas the overlap window alone only ever covered two-deep. Three simultaneous requests were enough to trip a spurious logout under the old behaviour.</p><p>The general point that I keep coming back to is: A single-use nonce sent over a lossy, concurrent transport will sometimes be presented stale. That is intrinsic to the design and not a defect in it. The bug was never that mismatches happened. The bug was terminating on an outcome that is inherent, benign, and tells you nothing about attackers. </p><p></p><h2 id="your-sso-logins-probably-arent-binding-at-all">Your SSO logins probably aren't binding at all</h2><p>Chrome issues the registration POST in the `SameSite` context of the navigation that carried the registration header. That's fine for a normal login on your site, the user POSTed a form to you, the response is same-site, the POST carries your session cookie. All good.</p><p>A SAML login doesn't work like that. The user lands on your callback via a chain the IdP initiated, so the registration POST that Chrome makes off that response counts as cross-site, and a <code>Lax</code> session cookie is withheld from it. The request arrives unauthenticated and gets a <code>401</code>.</p><pre><code>22:47:20 GET /account/ 200 ← Lax rides a top-level navigation
22:47:20 POST /dbsc/register 401 ← Lax does not ride this POST
</code></pre><p></p><p>And it's not merely a failure, it's a <em>spent</em> failure. Chrome marks the session as having a persistent HTTP error and won't retry. Offering the header on the SSO response doesn't just fail to bind that login; it burns the only attempt you get.</p><p>The fix is to record the intent rather than act on it: mark that this login should be bound, then make the actual offer on the first document request that isn't cross-site, which is typically the very next page the user loads. We detect that with <code>Sec-Fetch-Site</code>, and treat a missing header as "don't bother", on the grounds that a client not sending <code>Sec-Fetch-Site</code> isn't going to register anyway.</p><p></p><h2 id="fail-open-at-the-edges-fail-closed-at-the-gate">Fail open at the edges, fail closed at the gate</h2><p>DBSC has a lovely property, which is that adopting it cannot lock anyone out. A browser that doesn't support it ignores the registration header, never registers, and your gate, finding no binding, degrades to ordinary cookie authentication. Locking a current Firefox user out is <em>structurally impossible</em>. You don't need a compatibility check or a user-agent sniff; the protocol does it for you.</p><p>That's the correct behaviour, and the library goes out of its way not to break it. But it has an ugly failure mode when it meets bad data.</p><p>We had a routine that parsed a stored binding and returned <code>null</code> if it couldn't. Perfectly ordinary defensive code. Except the gate reads <code>null</code> as "there's no binding here", which, per the paragraph above, means "degrade gracefully to cookie auth".</p><p>So a <em>corrupt</em> binding didn't fail closed. It quietly downgraded a hardware-bound session to a bearer token, which is the entire thing we implemented DBSC to prevent! </p><p>It's not client-triggerable, to be clear, the realistic causes are internal: a serialiser mismatch, a truncated value, a schema skew across a deploy. But "only happens during a deploy" is not much comfort when the failure mode is silently disabling your session protection, and deploys are exactly when things are weird.</p><p>Now it throws, <code>null</code> means "no record", and only that. Present-but-unparseable is a distinct, loud, fail-closed condition. The distinction is documented in the storage interface, so anyone implementing their own backend knows which is which.</p><p>There's exactly one place we deliberately do the opposite, and I think the reasoning holds. Our "manage your sessions" screen shows a badge for whether each session is device-bound. If one row's binding can't be read, failing the whole page closed would mean a 500 for a page that is a <em>viewer</em>, not an enforcement point. So that badge is tri-state — bound, not bound, and unknown — and a bad read degrades to "unknown", surfaced as a visible alert rather than a silent "no".</p><p></p><h2 id="some-decisions-id-defend">Some decisions I'd defend</h2><p>A few smaller calls that I think generalise.</p><p><strong>Two overlap windows, deliberately asymmetric.</strong> We keep a one-generation history for the cookie <em>across</em> a successful refresh, but we explicitly discard the previous challenge on a successful refresh. That looks inconsistent, and it isn't: the refresh <code>200</code> delivers the new challenge synchronously with the new cookie, so there's no propagation window to bridge on that path — and <em>not</em> keeping it stops a spent challenge from being replayable. There's a comment in the source that says, more or less, "this asymmetry is intentional, do not consistency-refactor these into one," because I could see exactly how that tidy-up would go.</p><p><strong>No Web Crypto fallback.</strong> We were asked about supporting non-Chromium browsers with a software key, and I chose not to. The entire value of DBSC for me is the hardware-binding guarantee, and a software-bound key trades that away. A browser without DBSC degrading cleanly to plain cookie auth is expected. A browser holding a software key that your code treats as hardware-bound is not.</p><p><strong>Don't validate optional JWT claims speculatively.</strong> We verify the signature and the challenge. We deliberately do not check <code>iat</code>, <code>exp</code>, <code>typ</code>, <code>iss</code> or <code>aud</code>. The draft lists them as optional, browser emission isn't stable across versions, and our challenge TTL is already stricter than any <code>exp</code> a browser would plausibly emit. Adding checks the spec doesn't require buys you nothing, and we can tighten later if a revision mandates it.</p><p><strong>Set a content type even on empty responses.</strong> Small one. Our <code>403</code> challenge response has no body, but it declares <code>application/json</code> anyway — because in development a framework debug bar will cheerfully inject HTML into a response the browser is parsing strictly, and you will spend an hour on that. Ask me how I know.</p><p></p><h2 id="where-it-stands">Where it stands</h2><p>DBSC is now in open-beta at Report URI, it is being applied to a random downsample of our users that is increasing over time. The library is <a href="https://github.com/report-uri/dbsc-php?utm_source=scotthelme.co.uk">report-uri/dbsc-php</a> if you want it, or just want to read the comments, and most of what's above is in there, next to the code it explains.</p><p>DBSC is genuinely good. It closes a real hole that MFA doesn't touch, and it does it without any risk of locking users out. I'd encourage anyone running sessions at scale to look at it.</p><p>Just don't redirect the refresh endpoint 😅</p>]]></content:encoded></item><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'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>]]></description><link>https://scotthelme.co.uk/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'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/?utm_source=scotthelme.co.uk">WICG proposal</a> (<a href="https://github.com/WICG/connection-allowlists?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk"><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
</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">{
"type": "connection-allowlist",
"body": {
"url": "https://report-uri.com/account",
"connection": "https://blocked.example/collect.js",
"allowlist": ["https://api.example.com/*"],
"disposition": "report"
}
}</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?utm_source=scotthelme.co.uk">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>]]></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/?utm_source=scotthelme.co.uk" 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>]]></description><link>https://scotthelme.co.uk/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 – 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">Tranco</a> Top 1 Million (819,002 responding sites), powered by <a href="https://crawler.ninja/?utm_source=scotthelme.co.uk">Crawler.Ninja</a> and <a href="https://report-uri.com/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk"><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/?utm_source=scotthelme.co.uk" 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>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Certificate Authority</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<tr>
<td>Google Trust Services — WE1</td>
<td>193,069</td>
</tr>
<tr>
<td>Let's Encrypt — R12</td>
<td>59,705</td>
</tr>
<tr>
<td>Let's Encrypt — R13</td>
<td>59,496</td>
</tr>
<tr>
<td>Let's Encrypt — E8</td>
<td>48,754</td>
</tr>
<tr>
<td>Let's Encrypt — E7</td>
<td>48,564</td>
</tr>
<tr>
<td>Let's Encrypt — YE2</td>
<td>23,702</td>
</tr>
<tr>
<td>Let's Encrypt — YE1</td>
<td>23,467</td>
</tr>
<tr>
<td>Sectigo — Public Server Authentication CA DV R36</td>
<td>19,879</td>
</tr>
<tr>
<td>Let's Encrypt — YR1</td>
<td>19,159</td>
</tr>
<tr>
<td>Let's Encrypt — 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'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'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 – 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Validity period</th>
<th>Certificates</th>
<th>Share</th>
</tr>
</thead>
<tbody>
<tr>
<td>≤ 47 days</td>
<td>1,692</td>
<td>0.3%</td>
</tr>
<tr>
<td>48–90 days</td>
<td>509,744</td>
<td>77.4%</td>
</tr>
<tr>
<td>91–200 days</td>
<td>49,743</td>
<td>7.6%</td>
</tr>
<tr>
<td>201–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 — 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">why we need shorter lifetimes</a>, through <a href="https://scotthelme.co.uk/ballot-sc22-reduce-certificate-lifetimes/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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>
<!--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'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 — 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/?utm_source=scotthelme.co.uk" 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>
<!--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 – 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 – 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>
<!--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 — 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>
<!--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 – 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><em>Crawler.Ninja</em></a><em> and </em><a href="https://report-uri.com/?utm_source=scotthelme.co.uk"><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'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>]]></description><link>https://scotthelme.co.uk/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 – 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">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>
<!--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'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>
<!--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+ — 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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> ≥ 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'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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" rel="noreferrer">Report URI</a> and we'll make it easy. <a href="https://scotthelme.co.uk/goodbye-feature-policy-and-hello-permissions-policy/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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 – 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?utm_source=scotthelme.co.uk" 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-…</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've updated the crawler to track the <a href="https://scotthelme.co.uk/coop-and-coep/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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 – 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" rel="noreferrer">blogged about it being removed</a> back in 2020.</li><li><a href="https://scotthelme.co.uk/what-the-floc/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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>
<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'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>
<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/?utm_source=scotthelme.co.uk">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>—</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 — 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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><em>Crawler.Ninja</em></a><em> and </em><a href="https://report-uri.com/?utm_source=scotthelme.co.uk"><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/?utm_source=scotthelme.co.uk" 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>]]></description><link>https://scotthelme.co.uk/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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><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
</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
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'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>'
<title>Snapinsta - Download Instagram Videos, Reels, Stories for FREE</title>
</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
104.21.72.58
172.67.175.240
</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 \
-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 — 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?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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>]]></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/?utm_source=scotthelme.co.uk">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>]]></description><link>https://scotthelme.co.uk/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't Support Them"><p>Back in 2017, Troy Hunt and I built a little website called <a href="https://whynohttps.com/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk"><strong>whynopasskeys.com</strong></a></p><p></p><figure class="kg-card kg-image-card"><a href="https://whynopasskeys.com/?utm_source=scotthelme.co.uk"><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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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>]]></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.co.uk/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/?utm_source=scotthelme.co.uk"><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>
<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'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>
</tr>
<tr>
<td><strong>25 Apr 2026</strong></td>
<td>A Canvas customer-service representative opens the ticket. The payload fires <em>"in the rep's authenticated session."</em></td>
</tr>
<tr>
<td><strong>28–30 Apr 2026</strong></td>
<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>
</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 — 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>
</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'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:
default-src 'self';
script-src 'self' 'nonce-{random}';
style-src 'self' 'nonce-{random}';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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/?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">Trend Micro — What Is the Instructure Canvas Breach?</a><br><a href="https://ailearninsights.substack.com/p/what-can-we-infer-from-canvass-technical">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk"><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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">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?utm_source=scotthelme.co.uk">2026 Canvas security incident — 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’ve open-sourced <a href="https://packagist.org/packages/report-uri/dbsc-php?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><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>]]></description><link>https://scotthelme.co.uk/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’ve open-sourced <a href="https://packagist.org/packages/report-uri/dbsc-php?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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};
$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?utm_source=scotthelme.co.uk" rel="noreferrer">report-uri/dbsc-php</a><br>Source & docs: <a href="https://github.com/report-uri/dbsc-php?utm_source=scotthelme.co.uk">https://github.com/report-uri/dbsc-php</a><br>The spec: <a href="https://github.com/w3c/webappsec-dbsc?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><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.co.uk/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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk"><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/?utm_source=scotthelme.co.uk">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>]]></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’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>]]></description><link>https://scotthelme.co.uk/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’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>
<!--kg-card-begin: html-->
<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?utm_source=scotthelme.co.uk">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.co.uk">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'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/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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
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">{
"session_identifier": "9c2b7f3e1a",
"refresh_url": "/dbsc/refresh",
"scope": {
"origin": "https://report-uri.com",
"include_site": false
},
"credentials": [
{
"type": "cookie",
"name": "dbsc",
"attributes": "Path=/; Secure; HttpOnly; SameSite=Lax"
}
]
}</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
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: "def456"; id="9c2b7f3e1a"
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: "ghi789"; id="9c2b7f3e1a"
Sec-Secure-Session-Id: 9c2b7f3e1a
Content-Type: application/json
Content-Length: 312</code></pre><pre><code class="language-json">{
"session_identifier": "9c2b7f3e1a",
"refresh_url": "/dbsc/refresh",
"scope": {
"origin": "https://report-uri.com",
"include_site": false,
"scope_specification": []
},
"credentials": [
{
"type": "cookie",
"name": "dbsc",
"attributes": "Path=/; Secure; HttpOnly; SameSite=Lax"
}
]
}</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"
</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
Content-Type: text/html
Secure-Session-Challenge: "ghi789"; id="9c2b7f3e1a"
<html>
...
</html>
</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/?utm_source=scotthelme.co.uk#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)?utm_source=scotthelme.co.uk" rel="noreferrer">announcement</a>), and it looks like we could get it soon on <a href="https://chromestatus.com/feature/5140168270413824?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" rel="noreferrer">Device Bound Session Credentials (DBSC) | Chrome for Developers</a><br><a href="https://developer.chrome.com/blog/dbsc-windows-announcement?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" rel="noreferrer">Origin trial: Device Bound Session Credentials in Chrome | Chrome for Developers</a><br><a href="https://www.w3.org/TR/dbsc/?utm_source=scotthelme.co.uk" rel="noreferrer">Device Bound Session Credentials (W3C draft spec)</a><br><a href="https://github.com/w3c/webappsec-dbsc?utm_source=scotthelme.co.uk" 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.co.uk/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'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/?utm_source=scotthelme.co.uk"><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 = () => {
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 && (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'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'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)
? (/* 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'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?utm_source=scotthelme.co.uk" 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) {
const pp = document.permissionsPolicy || document.featurePolicy;
if (pp && !pp.allowsFeature('publickey-credentials-create')) {
throw new DOMException(
'The operation is not allowed by the document Permissions Policy.',
'NotAllowedError'
);
}
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'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
Object.defineProperty(document, 'featurePolicy', {
get: () => ({ allowsFeature: () => 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'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 = {
'create-credential': 'publickey-credentials-create',
'get-credential': 'publickey-credentials-get',
};
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 → 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 — fail closed — before any message reaches the
// background service worker or the native app.
function handleBridgeRequest(msg) {
if (!permissionsPolicyAllows(msg.name)) {
return respond(msg, {
type: 'create-credential-error',
data: { reason: 'permissions-policy-denied' },
});
}
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'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/?utm_source=scotthelme.co.uk" 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, {
configurable: false,
enumerable: true,
get() { return newMethod; }, // always returns 1P'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'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/?utm_source=scotthelme.co.uk" 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;
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 — but not the container.
const fakeContainer = new Proxy(realContainer, {
get(target, prop) {
if (prop === 'create') return hijackCreate;
const value = Reflect.get(target, prop, target);
return typeof value === 'function' ? value.bind(target) : value;
},
});
const shadow = { configurable: true, enumerable: true, get() { return fakeContainer; } };
try {
Object.defineProperty(Navigator.prototype, 'credentials', shadow);
installed = navigator.credentials === fakeContainer;
} catch (e) { /* try the instance next */ }
if (!installed) {
try {
Object.defineProperty(navigator, 'credentials', shadow);
installed = navigator.credentials === fakeContainer;
} catch (e) { /* give up */ }
}
}</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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" rel="noreferrer">Permissions Policy</a> and <a href="https://report-uri.com/products/content_security_policy?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" rel="noreferrer">2024</a>, <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/?utm_source=scotthelme.co.uk" 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/?utm_source=scotthelme.co.uk" 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?utm_source=scotthelme.co.uk" 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.co.uk" rel="noreferrer">release date</a> 15th May 2026<br>Bridge Spoof PoC (same-origin script): <a href="https://report-uri-demo.com/passkeys/5/?utm_source=scotthelme.co.uk" rel="noreferrer">link</a><br>Bridge Spoof PoC (third-party script): <a href="https://report-uri-demo.com/passkeys/6/?utm_source=scotthelme.co.uk" rel="noreferrer">link</a><br>Wrapper override PoC: <a href="https://report-uri-demo.com/passkeys/3/?protected&ref=scotthelme.co.uk" 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></channel></rss>
Raw headers
{
"age": "94367",
"alt-svc": "clear",
"cache-control": "public, max-age=0",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a39c019749c1dbb4-CMH",
"connection": "close",
"connection-allowlist-report-only": "(response-origin \"https://scotthelme.ghost.io/*\" \"https://c.disquscdn.com/*\" \"https://disqus.com/*\" \"https://cloudflareinsights.com/*\" \"https://cdn.jsdelivr.net/ghost/*\" \"https://storage.ghost.io/*\"); report-to=default",
"content-security-policy": "default-src 'self'; script-src 'self' 'report-sample' 'report-sha256' disqus.com c.disquscdn.com platform.instagram.com cdnjs.cloudflare.com scotthelme.disqus.com a.disquscdn.com go.disqus.com platform.twitter.com cdn.syndication.twimg.com syndication.twitter.com gist.github.com/ScottHelme/ static.cloudflareinsights.com js.stripe.com unpkg.com/@tryghost/ cdn.jsdelivr.net/ghost/ *.privacymanager.io; style-src 'self' 'report-sample' 'unsafe-inline' c.disquscdn.com a.disquscdn.com fonts.googleapis.com cdnjs.cloudflare.com platform.twitter.com assets-cdn.github.com github.githubassets.com unpkg.com/@tryghost/ cdn.jsdelivr.net/ghost/; img-src 'self' data: storage.ghost.io www.gravatar.com links.services.disqus.com referrer.disqus.com c.disquscdn.com cdn.syndication.twimg.com syndication.twitter.com pbs.twimg.com platform.twitter.com abs.twimg.com www.google-analytics.com; child-src www.instagram.com twitter.com fusiontables.googleusercontent.com fusiontables.google.com www.google.com disqus.com www.youtube.com syndication.twitter.com platform.twitter.com www.youtube-nocookie.com js.stripe.com https://drive.google.com/file/; connect-src 'self' syndication.twitter.com links.services.disqus.com scotthelme.ghost.io cloudflareinsights.com *.privacymanager.io; font-src 'self' cdnjs.cloudflare.com fonts.gstatic.com fonts.googleapis.com; form-action 'self' syndication.twitter.com; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; upgrade-insecure-requests; report-uri https://scotthelme.report-uri.com/r/d/csp/enforce; report-to default",
"content-type": "application/rss+xml; charset=utf-8",
"cross-origin-embedder-policy-report-only": "require-corp; report-to=\"default\"",
"cross-origin-opener-policy-report-only": "same-origin; report-to=\"default\"",
"cross-origin-resource-policy": "same-site",
"date": "Sat, 12 Sep 2026 04:00:37 GMT",
"etag": "W/\"48bdf-bSnjLNreedU3OH2z9mdcRcXPiUM\"",
"ghost-fastly": "true;production",
"nel": "{\"report_to\":\"default\",\"max_age\":10886400}",
"permissions-policy": "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()",
"referrer-policy": "strict-origin-when-cross-origin",
"report-to": "{\"group\":\"default\",\"max_age\":10886400,\"endpoints\":[{\"url\":\"https://scotthelme.report-uri.com/a/d/g\"}],\"include_subdomains\":true}",
"reporting-endpoints": "default=\"https://scotthelme.report-uri.com/a/d/g\"",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"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, 50, 0",
"x-cf-worker": "true",
"x-content-type-options": "nosniff",
"x-served-by": "cache-ams-eham8680089-AMS, cache-ams-eham8680089-AMS, cache-ams-eham8680089-AMS, cache-cmh1290043-CMH",
"x-timer": "S1789185637.052785,VS0,VE2",
"x-xss-protection": "1; mode=block; report=https://scotthelme.report-uri.com/r/d/xss/enforce",
"x-xss-pwnage": "<script>alert('XSS');</script>"
}
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.co.uk/",
"self": "https://scotthelme.co.uk/rss/",
"published": null,
"updated": "2026-09-11T01:47:50.000Z",
"generator": {
"label": "Ghost 6.64",
"version": null,
"url": null
},
"image": {
"title": "Scott Helme",
"url": "https://scotthelme.co.uk/favicon.png"
},
"authors": [],
"categories": [],
"items": [
{
"id": "6a9b30371c91af0001bd2b4f",
"title": "No Hacking Required: The Manchester Airports Group Data Breach",
"description": "<p>On 27 August 2026, Manchester Airports Group told customers that \"an unauthorised third party\" had stolen their data. Car park bookings, lounge bookings, Fast Track purchases for airport security and passport control, along with airport WiFi sign-ups across Manchester, Stansted and East Midlands. Roughly 8.8 million</p>",
"url": "https://scotthelme.co.uk/no-hacking-required-manchester-airports-group-data-breach/",
"published": "2026-09-07T09:13:49.000Z",
"updated": "2026-09-07T09:13:49.000Z",
"content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/mag.png\" alt=\"No Hacking Required: The Manchester Airports Group Data Breach\"><p>On 27 August 2026, Manchester Airports Group told customers that \"an unauthorised third party\" had stolen their data. Car park bookings, lounge bookings, Fast Track purchases for airport security and passport control, along with airport WiFi sign-ups across Manchester, Stansted and East Midlands. Roughly 8.8 million people.</p><p>A few days later, the group calling itself FulcrumSec claimed it, and said something that caught my eye. They said they didn't hack anything. They said they simply read an API key out of the website's JavaScript.</p><p>That's a very specific, very checkable claim. So I checked it.</p><p>Everything they described was there. Three keys, one per airport, in the page source, unrotated for over four years, for anyone to see.</p><p></p><h2 id=\"what-mag-said\">What MAG said</h2><p>MAG's <a href=\"https://www.manchesterairport.co.uk/help/data-security-incident/?utm_source=scotthelme.co.uk\">incident page</a> is what you'd expect. Email addresses, phone numbers, vehicle registrations and postcodes were taken. No bank or payment details. The incident was \"immediately contained\", specialist advisors were engaged, authorities were notified, and at no point was passenger safety or aviation security 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/09/MAG_Manchester_Airport_logo.svg\" class=\"kg-image\" alt=\"No Hacking Required: The Manchester Airports Group Data Breach\" loading=\"lazy\" width=\"186\" height=\"79\"></figure><p></p><p>What it doesn't say is <em>how</em>.</p><p>That's not unusual, and I'm not going to beat them up for it, but when the attacker makes a technical claim and the victim says nothing, the claim stands unchallenged, and I'd rather know whether it's true.</p><p></p><h2 id=\"what-fulcrumsec-said\">What FulcrumSec said</h2><p><a href=\"https://fulcrumsec.vg/mag/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Their claim</a>, as reported, was that they \"obtained access using airport-specific Iterable API credentials exposed in client-side JavaScript.\" The first few times I read that, I read it as \"iterable\", as in it could be iterated/enumerated. Not as \"Iterable\", the brand!</p><p></p><ol><li><strong>Iterable</strong> — a marketing automation platform. Customer profiles, segmentation, campaign history.</li><li><strong>Airport-specific</strong> — plural credentials, one per airport, not one shared key.</li><li><strong>Client-side JavaScript</strong> — shipped to the browser. Public by construction.</li></ol><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/09/fulcrum-sec.png\" class=\"kg-image\" alt=\"No Hacking Required: The Manchester Airports Group Data Breach\" loading=\"lazy\" width=\"2000\" height=\"667\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/09/fulcrum-sec.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/09/fulcrum-sec.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/09/fulcrum-sec.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/fulcrum-sec.png 2172w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>If all three are true, this isn't a breach in the way people picture one. There's no intrusion, no lateral movement, no malware. It's someone opening DevTools, good old 'F12 is hacking' stuff. </p><p></p><h2 id=\"where-to-look\">Where to look</h2><p>MAG's three airport sites are Next.js applications served from CloudFront:</p><pre><code>www.manchesterairport.co.uk → man.live.mag-dxp.com\nwww.stanstedairport.com → stn.live.mag-dxp.com\nwww.eastmidlandsairport.com → ema.live.mag-dxp.com\n</code></pre><p></p><p>I started with the obvious thing: curl the homepage and grep it. Nothing. The HTML mentions Iterable, but only as CMS field names like <code>iterableEventName</code>, <code>trackWithIterable</code>, <code>iterableCampaignId</code>. Configuration for which marketing event a signup form fires. No credentials. Same story in <code>__NEXT_DATA__</code>.</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/09/iterable-logo-auto.svg\" class=\"kg-image\" alt=\"No Hacking Required: The Manchester Airports Group Data Breach\" loading=\"lazy\" width=\"135\" height=\"20\"></figure><p></p><p>This is the tricky thing with problems like this; if you only look at the HTML, you find nothing. The secret isn't in the DOM. It's in a JavaScript bundle the document loads, one of about ninety chunk files with content-hashed names, and people don't often read those. Not the developer who shipped it, not the reviewer, and not whatever scanner MAG was running, clearly.</p><p></p><p>So I pulled all ninety and grepped those instead. Seventeen of them mention Iterable, and this is what's in them:</p><pre><code class=\"language-js\">function (e) {\n let t = `${n?.IterableApi?.Url}/api/users/${e}`;\n return r.A.get(t, { headers: { \"Api-Key\": n?.IterableApi?.Key } })\n .then(e => {\n e.data.user && window.sessionStorage.setItem(\"IterableUID\", e.data.user.userId)\n })\n}\n</code></pre><p>And alongside it:</p><pre><code class=\"language-js\">function (e) {\n let t = `${n?.IterableApi?.Url}/api/events/track`;\n return r.A.post(t, e, { headers: { \"Api-Key\": n?.IterableApi?.Key } })\n}\n</code></pre><p></p><p>There it is. The browser makes a <code>GET</code> to <code>https://api.iterable.com/api/users/{email}</code> with an <code>Api-Key</code> header, and gets back a user object.</p><p>That first call looks really bad. It takes an email address and returns that person's profile. It's a server-side credential doing a server-side job, from inside the browser on the client-side, on a normal page load, for anyone who cared to look.</p><p>The config object that feeds it lives in <code>pages/_app-*.js</code>:</p><pre><code class=\"language-json\">\"IterableApi\": { \"Key\": \"\", \"Url\": \"https://api.iterable.com\" }\n</code></pre><p></p><p>It's now empty. Which tells you two things: the mechanism is exactly what FulcrumSec described, and somebody has since removed the key.</p><p>An empty string is not evidence of what used to be there, though. For that I needed the history. </p><p></p><h2 id=\"the-internet-never-forgets\">The Internet never forgets</h2><p>The Internet Archive's CDX API indexes far more than homepages. It has JavaScript, and MAG's <code>_app</code> chunk has been captured for years:</p><pre><code class=\"language-bash\">curl \"http://web.archive.org/cdx/search/cdx?\\\nurl=manchesterairport.co.uk&matchType=domain&output=text\\\n&fl=timestamp,original,statuscode,digest\\\n&filter=original:.*chunks/pages/_app-.*\\\n&filter=statuscode:200&collapse=digest\"\n</code></pre><p></p><p>393 unique captures for Manchester going back to 2020, along with 445 for Stansted, and 208 for East Midlands.</p><p>Fetch any one of them with the <code>id_</code> modifier, which serves the original bytes rather than Wayback's rewritten version, decompress it, and grep away:</p><pre><code class=\"language-bash\">curl -s \"https://web.archive.org/web/20260802022711id_/\\\nhttps://www.manchesterairport.co.uk/_next/static/chunks/pages/_app-6c91337bee847d36.js\" \\\n | python3 -c \"import sys,brotli,re; \\\n s=brotli.decompress(sys.stdin.buffer.read()).decode(); \\\n print(re.search(r'\\\"IterableApi\\\":\\{[^}]*\\}', s).group(0))\"\n</code></pre><pre><code class=\"language-json\">\"IterableApi\": { \"Key\": \"5273…517c\", \"Url\": \"https://api.iterable.com\" }\n</code></pre><p></p><p>A 32-character hexadecimal Iterable API key, in a file Manchester Airports Group had been <strong>serving to the public since 2022</strong>.</p><p></p><h2 id=\"three-keys-over-four-years\">Three keys, over four years</h2><p>I sampled every quarter from 2020 to today across all three domains, then dug deeper to find the exact dates. Here's the timeline.</p><p></p><table>\n<thead>\n<tr>\n<th>Site</th>\n<th>Key</th>\n<th>Absent</th>\n<th>First seen exposed</th>\n<th>Last seen exposed</th>\n<th>Emptied by</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>East Midlands</td>\n<td><code>c72b…0086</code></td>\n<td>2022-06-05</td>\n<td><strong>2022-06-23</strong></td>\n<td>2026-08-16</td>\n<td>no capture after</td>\n</tr>\n<tr>\n<td>Stansted</td>\n<td><code>5969…ccf7</code></td>\n<td>2022-06-16</td>\n<td><strong>2022-06-28</strong></td>\n<td>2026-08-23</td>\n<td><strong>2026-08-25 20:48</strong></td>\n</tr>\n<tr>\n<td>Manchester</td>\n<td><code>5273…517c</code></td>\n<td>2022-07-03</td>\n<td><strong>2022-07-11</strong></td>\n<td>2026-08-22</td>\n<td>2026-08-27 21:49</td>\n</tr>\n</tbody>\n</table>\n<p></p><p>Three distinct keys, one per airport, just as FulcrumSec had said, it was \"airport-specific\".</p><p>The keys appear during a rollout in June and July 2022, and then <strong>the exact same value is present in every single capture until August 2026</strong>. They were <em>never</em> rotated. Not once, in more than four years.</p><p>Read the timeline the other way round and it's worse. Anyone who looked at that page source on any day between June 2022 and August 2026 could have taken the key. FulcrumSec just happen to be the ones who told us. There is no way to know, from the outside, who else did, and the honest answer is that MAG can't know either without going back through four years of Iterable API logs, if they even have four years of Iterable API logs.</p><p>One detail that does stand out in this, though: Stansted's key went empty by 25 August,<strong> at least two days before MAG went public.</strong> That sounds like a normal incident response, find it, kill it, then disclose, but it does put a date on when they knew.</p><p>For East Midlands, the Internet Archive has no capture of that chunk after 16 August, so I can't precisely date its remediation. It was empty when I looked, though, and that we can prove.</p><p></p><h2 id=\"what-the-key-could-actually-do\">What the key could actually do</h2><p>Having been a MAG customer many times myself, I'm in the breach many times over, as confirmed by <a href=\"https://haveibeenpwned.com/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">HIBP</a>, so I've had these API keys in my browser and sent these exact requests many times before! All you had to do was visit the right page and your browser would send the request as intended. </p><p></p><pre><code class=\"language-bash\">scott@Gaming-Rig:~/code$ curl -s \"https://api.iterable.com/api/users/mag%40scotthelme.co.uk\" -H \"Api-Key: 527snip17c\" | jq .\n{\n \"msg\": \"Disabled API key\",\n \"code\": \"Unauthorized\",\n \"params\": {\n \"ip\": \"snip\",\n \"endpoint\": \"/api/users/mag%40scotthelme.co.uk\"\n }\n}\nscott@Gaming-Rig:~/code$ curl -s \"https://api.iterable.com/api/users/mag%40scotthelme.co.uk\" -H \"Api-Key: 596snipcf7\" | jq .\n{\n \"msg\": \"Disabled API key\",\n \"code\": \"Unauthorized\",\n \"params\": {\n \"ip\": \"snip\",\n \"endpoint\": \"/api/users/mag%40scotthelme.co.uk\"\n }\n}\nscott@Gaming-Rig:~/code$ curl -s \"https://api.iterable.com/api/users/mag%40scotthelme.co.uk\" -H \"Api-Key: c72snip086\" | jq .\n{\n \"msg\": \"Disabled API key\",\n \"code\": \"Unauthorized\",\n \"params\": {\n \"ip\": \"snip\",\n \"endpoint\": \"/api/users/mag%40scotthelme.co.uk\"\n }\n}</code></pre><p></p><p>I'm glad to see that the keys are disabled at least, it means I don't have to reach out to Iterable to disclose and that the breach did stop when the keys were pulled from the production site.</p><p>You didn't need curl to test it anyway. The site's own code calls <code>GET /api/users/{email}</code> with this key and reads <code>data.user.userId</code> off the response. That path returns a user's profile. So the key had, at minimum, read access to customer records by email address.</p><p>Given that, the rest follows without any further cleverness. Feed it a list of addresses and you're enumerating a customer database over HTTPS from a coffee shop. FulcrumSec described roughly 86GB compressed, extracting to around 640GB, including a 21.5GB Manchester customer export and about 200,000 records covering upcoming travel in 2026 with dates, times and booking references tied to names.</p><p>That last category is the one that actually worries me. Email addresses leak and life goes on, sadly it's a daily thing now. \"This named person, with this car registration, is flying on this date\" is a different kind of breach, though, and to their limited credit FulcrumSec appeared to recognise it, saying they might redact forward travel records.</p><p>But going back to that previous point, something didn't sit right with me. I use custom email addresses per service, and the MAG breach was no different: <code>mag@scotthelme.co.uk</code></p><p>How on Earth did they enumerate that?</p><p></p><h2 id=\"it-wasnt-email-enumeration\">It wasn't email enumeration</h2><p>That question was nagging at me, so I dug deeper. The call in MAG's code looks up one person at a time:</p><pre><code>GET /api/users/{email}\n</code></pre><p></p><p>So how do you get to 8.8 million records? You'd need the email addresses first, and you can't guess them all. I know I can't be the only person using service-specific aliases like I do.</p><p>Turns out it's easy, you don't enumerate. You export!</p><pre><code>GET /api/export/data.csv?dataTypeName=user&range=All\n</code></pre><p></p><p><code>dataTypeName</code> is the only required parameter. One request, the entire user table. There's a JSON variant, and a list-walking route via <code>/api/lists</code> into <code>/api/lists/getUsers</code>, which returns every address on a list without pagination.</p><p><code>dataTypeName</code> accepts 51 values. Among them:</p><p></p><table>\n<thead>\n<tr>\n<th>Value</th>\n<th>What it returns</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>user</code></td>\n<td>every customer profile</td>\n</tr>\n<tr>\n<td><code>purchase</code></td>\n<td>transaction history</td>\n</tr>\n<tr>\n<td><code>customEvent</code></td>\n<td>whatever MAG defined — bookings, parking</td>\n</tr>\n<tr>\n<td><code>emailSend</code></td>\n<td>every message sent to you</td>\n</tr>\n<tr>\n<td><code>emailOpen</code> / <code>emailClick</code></td>\n<td><strong>IP address and user agent, per open</strong></td>\n</tr>\n</tbody>\n</table>\n<p></p><p>That last row explains something that puzzled me in the <a href=\"https://haveibeenpwned.com/Breach/ManchesterAirportsGroup?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Have I Been Pwned entry for this breach</a>: alongside names and vehicle registrations, the compromised data classes include IP addresses and browser user agent strings. Those didn't come from the website. They came from tracking pixels in marketing emails, exported by data type.</p><p></p><h2 id=\"and-it-could-write\">And it could write</h2><p>The same global auth covers the destructive half of the API too:</p><pre><code>DELETE /api/users/{email} delete a customer\nDELETE /api/lists/{listId} delete a list\nPOST /api/users/forget GDPR erasure\nPOST /api/users/bulkUpdate mass-rewrite profiles\nPOST /api/users/updateEmail change someone's address\n</code></pre><p></p><p>There's no indication FulcrumSec did any of this, and I'm glad, but they could have just nuked everything from orbit and MAG would have been really screwed. For four whole years, the capability to delete Manchester Airports Group's database was a <code>view-source</code> away. This wasn't only a confidentiality exposure. It was a colossal integrity and availability exposure too, and MAG just got lucky. How do they now trust any of the data that remains in the database? </p><p></p><h2 id=\"how-can-i-be-sure\">How can I be sure?</h2><p>Because looking at the published data, my own records gave it away. Buried in a few hundred fields of parking bookings and Fast Track purchases were these:</p><pre><code class=\"language-json\">\"itblInternal.regionCode\": \"GB\",\n\"itblInternal.isAnonymousUser\": false,\n\"itblInternal.isUnknownUser\": false,\n\"itblInternal.phoneType\": \"MOBILE\",\n\"itblInternal.phoneCountry\": \"GB\",\n\"itblInternal.emailDomain\": \"scotthelme.co.uk\",\n\"itblDS.brandAffinityLabel\": \"negative\",\n\"signupSource\": \"UpdateSubscriptionsAPI\"\n</code></pre><p></p><p><code>itbl</code> is Iterable's own system namespace. Every one of those values is something Iterable derives rather than something a customer supplies, my phone number parsed into a type and a country, the domain split off my address, a region code inferred. <code>itblDS</code> is Iterable Data Science, and Brand Affinity is a machine-learning score their platform generates. Apparently mine is negative, which feels fair.</p><p>Nothing in a car park booking produces a machine-learning affinity score. That field was written by Iterable, which means this record passed through Iterable. Whatever was taken carried Iterable's derived fields, Iterable's list and channel IDs, and Iterable's export flattening. And the group who took it said they used Iterable credentials from client-side JavaScript, which is where three of them had been sitting for four years, and which MAG revoked in the days before disclosing.</p><p><code>signupSource</code> names the API method that created the record: <code>POST /api/users/updateSubscriptions</code>.</p><p>The record also carries Iterable's own subscription state, <code>emailListIds</code>, <code>unsubscribedChannelIds</code>, <code>subscribedMessageTypeIds</code>, which appear in exactly two places: the response from <code>GET /api/users/{email}</code>, and the output of the user export.</p><p>Even the shape is a tell. Every nested value appears twice, once as an object and once flattened into a dotted key:</p><pre><code class=\"language-json\">\"cip.lastBooking.parking.vehicleReg\": \"HE23LME\",\n\"cip.lastBooking.parking\": { \"vehicleReg\": \"HE23LME\", ... }\n</code></pre><p></p><p>That's what Iterable's export produces when it flattens <code>dataFields</code> into columns while keeping the original object. So the data left Iterable. Now, which type of key?</p><p>Iterable publish their full OpenAPI specification, unauthenticated, at <code>https://api.iterable.com/api-docs</code>. The <code>security</code> block is global, every endpoint takes the same <code>Api-Key</code> header. But each endpoint is also tagged with the key types permitted to call it, in an extension called <code>x-iterable-api-key-types</code>:</p><pre><code class=\"language-text\">POST /api/events/track Server-side, JavaScript, Mobile, Web\nPOST /api/users/update Server-side, JavaScript, Mobile, Web\nGET /api/users/{email} Server-side\nGET /api/export/data.csv Server-side\nGET /api/lists/getUsers Server-side\n</code></pre><p></p><p>Of 131 endpoints, 98 are server-side only. Twelve accept a browser key.</p><p>At first, I thought the code proves the key class: <code>GET /api/users/{email}</code> is server-side only, MAG's page called it, therefore server-side key, right? But look at that call again, it ends in <code>.catch(e => { console.warn(e) })</code>, and the <code>IterableUID</code> it writes is read by nothing. With a browser-scoped key the event tracking would still have worked, the profile lookup would have 401'd into a swallowed promise, and the site would have continued. It could have been failing silently since 2022 and nobody would have known.</p><p>The export is what settles it. Producing that record requires the user export or the list endpoints, and both are server-side only. So a server-side key was used. The only credentials in play were three server-side keys published in MAG's own JavaScript, the ones FulcrumSec named as their route in, and the ones MAG revoked, all three, in the days before going public.</p><p>Iterable's fingerprints are on the data, and Iterable's spec says what key type was required to access that data. I'm confident that, on balance, this is most likely how the data was stolen.</p><p></p><h2 id=\"so-what-was-this-api-key-actually-used-for\">So what was this API key actually used for?</h2><p>I went looking for the code that needed this key, expecting something substantial. It's 1,119 bytes, lazy-loaded from <code>/_next/static/chunks/95707.a17f18ff1a88c71f.js</code>:</p><pre><code class=\"language-js\">useEffect(() => {\n const email = new URLSearchParams(location.search).get(\"email\");\n const userId = sessionStorage.getItem(\"IterableUID\")\n || new URLSearchParams(location.search).get(\"userId\");\n\n const payload = {\n eventName: props.eventName || \"pageView\",\n createdAt: Date.now(),\n dataFields: { pageUrl: props.pageUrl || location.href.split(\"?\")[0] }\n };\n if (email) payload.email = email;\n if (userId) payload.userId = userId;\n if (props.templateId) payload.templateId = props.templateId;\n if (props.campaignId) payload.campaignId = props.campaignId;\n\n if (email || userId) trackEvent(payload); // POST /api/events/track\n if (email) lookupUser(email); // GET /api/users/{email}\n}, []);</code></pre><p></p><p>That's it. That's the whole reason. </p><p>Iterable's marketing emails link back to the site with the recipient's address sitting in the query string, <code>?email=you@example.com&userId=...</code>. This component reads it, fires a <code>pageView</code> event back to Iterable, and the campaign gets credit for your visit. Attribution. That's the job. </p><p>And the second call, the dangerous one, <code>GET /api/users/{email}</code>, the arbitrary profile read? It writes the result into <code>sessionStorage</code> under <code>IterableUID</code>, and the same component reads it back on later page views in the session, so subsequent visits still carry an identifier.</p><p>So a credential with access to all but ten of 131 endpoints, including a full database export and the ability to delete customers, was published to every visitor of three airport websites for over four years, so that a marketing email could get attribution credit for a page view. And half of what it did was populate a cache that nothing reads.</p><p></p><h2 id=\"this-is-not-a-mag-problem\">This is not a MAG problem</h2><p>It would be easy to file this under \"airport group made silly mistake\", but it isn't that.</p><p>The pattern is: a marketing or analytics platform gives you an API key, the integration guide shows you a <code>fetch</code> call, and it works. It works in dev, it works in staging, it works in prod. Nothing fails. No error appears. No test goes red. The application behaves perfectly while a credential with server-side authority sits in a file that every visitor downloads.</p><p>And look at what the browser was actually doing with it. I searched every chunk the site loads, and every archived bundle back to 2022, for any sign of the valuable data being sent from the page including vehicle registrations, booking references, travel dates. Nothing. Not one field, ever. The only thing the browser ever wrote to Iterable was this:</p><pre><code class=\"language-js\">{ email, eventName, dataFields: { phoneNumber, airportCode } }\n</code></pre><p></p><p>A newsletter signup. Your email, your phone number, and which airport you're a customer of.</p><p>Everything that made this breach worth 640GB, the parking history, the registrations, and the forward travel, was put into Iterable by a server-side sync from MAG's booking platform. That integration was fine. It was never exposed.</p><p>So the cause of this whole issue is clear, and it's worse than \"they leaked a key\": email click tracking was given a credential scoped to the entire customer database,<strong> in order to record a click.</strong> The read authority and the write requirement were four orders of magnitude apart, and nothing in the toolchain notices a mismatch like that.</p><p>And there is no natural moment at which anyone notices. Secret scanning runs against your repository, and this key probably <em>was</em> in a config file or a CI variable, which is exactly where it's supposed to be. The leak happens at build time, when it gets baked into a bundle. A pen test scopes your login and your booking flow, not the twelfth webpack chunk. A WAF sees nothing, because there's no malicious request to your origin; the traffic goes straight from the victim's browser to <code>api.iterable.com</code> and never touches your infrastructure at all.</p><p>Which is the part I find most uncomfortable. This vector never touched MAG's servers. None of it would appear in a log MAG owns. The entire incident happened in other people's browsers and on somebody else's API, using a credential they published themselves.</p><p>The uncomfortable detail is that this one was avoidable by reading the manual. Iterable's documentation carries it in a box marked WARNING:</p><figure class=\"kg-card kg-image-card kg-card-hascaption\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/360043464871-API-Keys.png\" class=\"kg-image\" alt=\"No Hacking Required: The Manchester Airports Group Data Breach\" loading=\"lazy\" width=\"706\" height=\"179\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/09/360043464871-API-Keys.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/360043464871-API-Keys.png 706w\"><figcaption><a href=\"https://support.iterable.com/hc/en-us/articles/360043464871-API-Keys?utm_source=scotthelme.co.uk\"><span style=\"white-space: pre-wrap;\">source</span></a></figcaption></figure><p></p><blockquote>\"Never embed a server-side API key in client-side code (whether JavaScript, a mobile application, or otherwise), since they can be used to access project data. Use these server-side API keys only when making API calls from your servers.\"</blockquote><p></p><p>They issue client-side key types that reach only the handful of endpoints front-end code actually needs, and JWT authentication is on by default when you create one. It's been mandatory for new organisations since 30 August 2022.</p><p>The keys in these bundles landed in June and July 2022. They missed that cutoff by weeks, and then nobody looked again for four years.</p><p>I've spent years arguing that the browser is a part of your attack surface that most organisations just don't monitor. This is that argument with 8.8 million records attached to it.</p><p></p><h2 id=\"what-to-actually-do-about-it\">What to actually do about it</h2><p>There are a few important steps you can take right now, and considerations you take forwards:</p><p></p><ol><li><strong>Grep your own bundles today.</strong> Not your repo, your built, deployed JavaScript. Pull every script your production pages load and search for <code>api_key</code>, <code>apiKey</code>, <code>Api-Key</code>, <code>token</code>, <code>secret</code>, and 32-character hex strings. This takes an afternoon and it is the highest-value hour of work in this entire post.</li><li><strong>Assume anything ever shipped to a browser is public forever.</strong> Rotating the key fixes the future. It does not remove it from the Internet Archive, from someone's <code>curl</code> history, or from a threat actor's notes. Every one of these three keys is still sitting in a public archive right now.</li><li><strong>Move the call server-side.</strong> If your browser needs data from Iterable, Klaviyo, Braze or anything like them, it should ask <em>your</em> backend, which holds the credential and enforces who can ask for what. That's a small proxy endpoint, not an architecture project.</li><li><strong>Use the key type the vendor built for browsers.</strong> Iterable issue five, and the client-side ones reach a handful of endpoints instead of all of them. A leaked browser key can act as one person. A leaked server key can export everyone. Same header, same integration effort, completely different blast radius.</li><li><strong>Monitor what your pages actually load and where they send data.</strong> You cannot review ninety content-hashed chunks by hand every deploy. Something has to watch the scripts on your pages and the destinations they talk to, and tell you when either changes.</li></ol><p></p><p>That last one is the reason I do what I do, so yes I'm a little biased, but it is <strong>exactly</strong> what <a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Report URI</a> was built to do.</p><p></p><h2 id=\"the-uncomfortable-truth\">The uncomfortable truth</h2><p>While confirming the Iterable key is gone, I looked at the rest of the same configuration object that still ships to browsers today.</p><p>It is not empty. There are several other credential-shaped values in it, including one paired with an AWS API Gateway endpoint.</p><p>I'm not publishing those, but they're as public as the others, and I'm not sure a couple of those should be public. But it does illustrate the point better than anything else I could write: the Iterable key was removed because an attacker forced the issue and 8.8 million people paid for it. The ones sitting next to it are still there, so, has anybody looked?</p><p></p><h2 id=\"want-the-people-who-did-this-on-your-team\">Want the people who did this on your team?</h2><p>Everything in this post came from reading JavaScript that three different airports published to the world. No exploit, no access, no privileged information. A key sitting in the seventeenth of ninety chunk files, and four years of archived copies to date it against.</p><p>That's the uncomfortable bit, this was discoverable the entire time. It was discoverable in 2022, and in 2023, and on every single deploy until August 2026. Nobody looked, because looking means reading ninety content-hashed bundles after every release, and nobody really does that.</p><p>So by all means, go and grep your bundles. You should. But understand what a grep gives you: a snapshot of today. Run it in May 2022 against Manchester Airport and you'd have found nothing at all, the key landed in July 2022. The exposure isn't a state you can check once. It's a thing that arrives on a Tuesday in a chunk nobody reviewed, and then stays for four years.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/report-uri-logo.png\" class=\"kg-image\" alt=\"No Hacking Required: The Manchester Airports Group Data Breach\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/09/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/report-uri-logo.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><p>That's the problem <a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Report URI</a> was built for. Not reporting on it after someone else tells you, watching the scripts your pages actually load, and the destinations they actually send data to, and telling you the moment either one changes. If MAG had been watching, the question wouldn't have been \"did anyone take the key\". It would have been \"why is our booking site talking to <code>api.iterable.com</code> from the browser?\", and it would have been asked in July 2022, not by an extortion group in August 2026.</p><p>Your website is running code right now. Do you know what code it's running?</p><p><a href=\"https://report-uri.com/register?plan=business2025&ref=scotthelme.co.uk\"><strong>Start your free trial, no credit card required →</strong></a></p><p></p>",
"image": {
"url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/mag.png",
"title": null
},
"media": [
{
"url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/mag.png",
"image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/09/mag.png",
"title": null,
"length": null,
"type": "image",
"mimeType": null
}
],
"authors": [
{
"name": "Scott Helme",
"email": null,
"url": null
}
],
"categories": [
{
"label": "FulcrumSec",
"term": "FulcrumSec",
"url": null
},
{
"label": "MAG",
"term": "MAG",
"url": null
},
{
"label": "javascript",
"term": "javascript",
"url": null
}
]
},
{
"id": "6a8c17b0f10eb90001ef02a9",
"title": "The ultimate road trip combo: Starlink Mini + UniFi Travel Router",
"description": "<p>I recently went on an epic road trip around Europe, covering 1,645 miles (2,647 km), and we took in some amazing sights and locations. As a tech geek, I was worried about my connectivity on the trip, so before we set off, I came up with a plan!</p>",
"url": "https://scotthelme.co.uk/the-ultimate-road-trip-combo-starlink-mini-unifi-travel-router/",
"published": "2026-08-24T13:50:51.000Z",
"updated": "2026-08-24T13:50:51.000Z",
"content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/porsche-road-trip.png\" alt=\"The ultimate road trip combo: Starlink Mini + UniFi Travel Router\"><p>I recently went on an epic road trip around Europe, covering 1,645 miles (2,647 km), and we took in some amazing sights and locations. As a tech geek, I was worried about my connectivity on the trip, so before we set off, I came up with a plan!</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/08/image-2.png\" class=\"kg-image\" alt=\"The ultimate road trip combo: Starlink Mini + UniFi Travel Router\" loading=\"lazy\" width=\"768\" height=\"1038\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/image-2.png 768w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h2 id=\"did-we-really-need-this\">Did we really need this?</h2><p>On a more serious note, we all like to have connectivity, but I am also responsible for running my own company, <a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Report URI</a>, which has other staff members. I wanted to make sure that I had at least somewhat reliable connectivity whilst I was away as, anyone running their own company can confirm, you're never really 'not working' when everything falls to you.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/report-uri-logo-1.png\" class=\"kg-image\" alt=\"The ultimate road trip combo: Starlink Mini + UniFi Travel Router\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/report-uri-logo-1.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><p>Of course we could get an eSIM for my phone, and then I could tether, but my wife would also need connectivity, so that's another eSIM, and then on the longer stints in the car it'd be nice to give my son some connectivity too... It was quickly looking like it wouldn't be practical, and there were also concerns about the times we wouldn't have any cellular connectivity too.</p><p></p><h2 id=\"the-two-devices-we-bought-for-the-trip\">The two devices we bought for the trip</h2><p>Being quite the fan of <a href=\"https://scotthelme.co.uk/tag/ubiquiti/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Ubiquiti kit</a>, I wanted to try out the <a href=\"https://uk.store.ui.com/uk/en/products/utr?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Unifi Travel Router</a>. This is a tiny device that can connect to an external Wi-Fi network and then broadcast your usual home Wi-Fi networks for all of your devices to connect to. Super handy because all of my devices then just connect to my home network without any configuring or joining the hotel Wi-Fi, and, the UTR can VPN me home so all of my usual devices and services are available if I want them.</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/08/Screenshot-2026-08-24-111730.png\" class=\"kg-image\" alt=\"The ultimate road trip combo: Starlink Mini + UniFi Travel Router\" loading=\"lazy\" width=\"469\" height=\"587\"></figure><p></p><p>The other bit of kit I purchased was a Starlink Mini. I got mine from a <a href=\"https://www.currys.co.uk/products/starlink-mini-satellite-antenna-and-wifi-router-kit-dualband-10269577.html?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">local electronics retailer</a> that had a deal on, and I'm sure you can find a suitable retailer in your area. I also bought a carry case from Amazon along with some accessories to mount it to the panoramic glass roof in the Porsche, and a 12V 'cigarette' socket adapter that could provide the higher power the Starlink needs from the car. Note: the USB-C ports in your car might not provide enough power for the Starlink, mine didn't, hence the 12V adapter.</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/08/IMG_9898.jpeg\" class=\"kg-image\" alt=\"The ultimate road trip combo: Starlink Mini + UniFi Travel Router\" loading=\"lazy\" width=\"1645\" height=\"2193\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/IMG_9898.jpeg 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/IMG_9898.jpeg 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/08/IMG_9898.jpeg 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/IMG_9898.jpeg 1645w\" 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/08/IMG_9897.jpeg\" class=\"kg-image\" alt=\"The ultimate road trip combo: Starlink Mini + UniFi Travel Router\" loading=\"lazy\" width=\"2000\" height=\"2667\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/IMG_9897.jpeg 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/IMG_9897.jpeg 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/08/IMG_9897.jpeg 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/08/IMG_9897.jpeg 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/08/IMG_9472.jpeg\" class=\"kg-image\" alt=\"The ultimate road trip combo: Starlink Mini + UniFi Travel Router\" loading=\"lazy\" width=\"2000\" height=\"2667\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/IMG_9472.jpeg 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/IMG_9472.jpeg 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/08/IMG_9472.jpeg 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/08/IMG_9472.jpeg 2400w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>It's quite a nice setup, out of the way, and it automatically powers up when the car turns on with no effort from us. There's also a nice feature of being able to leave the 12V power source in the boot (trunk, for the Americans) turned on for a period of time after you leave the car and lock it, so we can keep connectivity in the general vicinity before it powers down. </p><p></p><h2 id=\"how-well-did-the-unifi-travel-router-work\">How well did the UniFi Travel Router work?</h2><p>We used the UTR on our first day of the trip as we drove a few hundred miles to the south coast of England to take the ferry to Spain on the Portsmouth to Santander route. The Wi-Fi on the ferry is not free, and I didn't fancy paying for a whole bunch of devices either. Instead, I could buy a Wi-Fi pass, join the UTR to the ferry Wi-Fi and then the UTR will broadcast our usual Wi-Fi network for all of our devices! I had Wi-Fi on my phone, my laptop, my wife had it on her devices and my son had it on his. This saving alone paid for ~50% of the cost of the UTR itself! We didn't have a balcony or any external area we could place the Starlink, and the window in our room was tiny, but the UTR worked out great for the two nights on the ferry. It was a similar story in every hotel and Airbnb we stayed in for the whole trip. Rather than arrive at a hotel and have to go through the Wi-Fi joining process on a whole bunch of devices, I would just power up the UTR, join it to the Wi-Fi, and all of our devices would automatically connect to our normal home Wi-Fi. This was a very nice convenience feature.</p><p>One unexpected benefit of the UTR came in a hotel with poor Wi-Fi signal. At the end of the room with the seats and desk, there was a single bar of signal, nothing worked and it dropped regularly. By the door to the hallway, signal was great, but you can't sit there with your laptop and do emails! I took a phone charger to power the USB-C UTR, plugged it in by the door and it had a good signal. It then broadcast our usual Wi-Fi network into the room and everyone had great Wi-Fi, problem solved!</p><p>Overall, I'd say the UTR is definitely worth it. I didn't use the VPN feature much, it wasn't what I needed or wanted from it, but I did test it out and it worked perfectly. The convenience of joining a single device to an external Wi-Fi network and then having all of your devices automatically connect is really nice, and the portability of it allows you to move it around for the best signal strength. If you're having to pay for Wi-Fi somewhere, it's a no-brainer. (The UTR can also do Ethernet backhaul, but I never needed to try that out)</p><p></p><h2 id=\"how-well-did-the-starlink-mini-work\">How well did the Starlink Mini work?</h2><p>We set everything up with the Starlink Mini before we left home so as soon as we hit the road, we were on the built-in Wi-Fi network on all of our devices. In many ways, that's all I have to say about the Starlink... We plugged it in, set it up, mounted it, and that's it. It just worked. Every single day, every single time. The damn thing is annoyingly flawless.</p><p>I'm not one for hype or fanfare, but I feel like Starlink will be a transformational technology. It doesn't matter where you are, as long as you can see the sky, you're connected. On some of the mountain roads and coastal roads we took on the trip, we had no phone service at all. Genuinely zero. We were still fully connected thanks to the Starlink. Even with the multiple speed tests we did at various locations, we were always seeing 100Mbps+, and we even did some high speed tests at ̶2̶0̶0̶k̶m̶/h̶ 140km/h. It turns out how fast you're moving is kind of irrelevant because the satellite you're connected to is going at 27,600 km/h, so you're basically not moving as far as it's concerned.</p><p>The only thing I had to make a change for was that I normally do wireless Apple CarPlay, but I had to switch to wired Apple CarPlay so my phone could connect to the Starlink Wi-Fi network for data instead of the Porsche Wi-Fi for CarPlay. That's it. It was awesome to be able to have stable voice calls, stable video calls, large attachments are no issue, you just use your devices like normal without any consideration for data or connectivity.</p><p></p><h2 id=\"theyre-the-ultimate-combo\">They're the ultimate combo</h2><p>You might not be planning your own road trip, and I can definitely see how each of these devices has its own benefits that would justify buying either of them in isolation. The UTR isn't much bigger than a credit card and ~1cm thick, but provides huge value and benefits. The Starlink is a different beast in the physical dimensions, but it's now going to form part of our regular travel kit. Since the European road trip, we've already used it on two more trips including a long weekend in a remote Scottish location, and during a race weekend in the paddock when we're uploading and downloading huge video and telemetry files and the Wi-Fi at the race track just can't provide reliable or high-speed connectivity.</p><p>Hopefully that gives a nice summary of the benefits of these two devices, but if you want it in a single sentence for each:</p><p>The Starlink will give you connectivity where you otherwise have none.</p><p>The UTR will make whatever connectivity you have behave like you're at home.</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/08/IMG_9799.jpeg\" class=\"kg-image\" alt=\"The ultimate road trip combo: Starlink Mini + UniFi Travel Router\" loading=\"lazy\" width=\"2000\" height=\"2667\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/IMG_9799.jpeg 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/IMG_9799.jpeg 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/08/IMG_9799.jpeg 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w2400/2026/08/IMG_9799.jpeg 2400w\" sizes=\"(min-width: 720px) 720px\"></figure>",
"image": {
"url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/porsche-road-trip.png",
"title": null
},
"media": [
{
"url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/porsche-road-trip.png",
"image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/porsche-road-trip.png",
"title": null,
"length": null,
"type": "image",
"mimeType": null
}
],
"authors": [
{
"name": "Scott Helme",
"email": null,
"url": null
}
],
"categories": [
{
"label": "Ubiquiti",
"term": "Ubiquiti",
"url": null
},
{
"label": "UniFi",
"term": "UniFi",
"url": null
},
{
"label": "Travel Router",
"term": "Travel Router",
"url": null
},
{
"label": "Starlink",
"term": "Starlink",
"url": null
}
]
},
{
"id": "6a87306bf001a000018b8f0a",
"title": "Introducing dbsc.dev: Does Your Browser Support DBSC?",
"description": "<p>Every other web platform feature I've ever written about, I've been able to test in some easy way. Open DevTools, type the name of the thing, see if it's there. Device Bound Session Credentials doesn't work like that: there is no way</p>",
"url": "https://scotthelme.co.uk/introducing-dbsc-dev-does-your-browser-support-dbsc/",
"published": "2026-08-21T13:42:10.000Z",
"updated": "2026-08-21T13:42:10.000Z",
"content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-dev.png\" alt=\"Introducing dbsc.dev: Does Your Browser Support DBSC?\"><p>Every other web platform feature I've ever written about, I've been able to test in some easy way. Open DevTools, type the name of the thing, see if it's there. Device Bound Session Credentials doesn't work like that: there is no way for a page to ask the browser whether it supports DBSC. None! So I built <a href=\"https://dbsc.dev/?utm_source=scotthelme.co.uk\">dbsc.dev</a>, which answers the question the only way I could think to answer it.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://dbsc.dev/?utm_source=scotthelme.co.uk\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/image-1.png\" class=\"kg-image\" alt=\"Introducing dbsc.dev: Does Your Browser Support DBSC?\" loading=\"lazy\" width=\"1428\" height=\"898\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/08/image-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/image-1.png 1428w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"a-feature-you-cannot-feature-detect\">A Feature You Cannot Feature-Detect</h3><p>Here's the thing that makes DBSC unusual. Almost everything we bolt onto the web platform comes with a hook you can poke at from JavaScript.</p><pre><code class=\"language-js\">if (window.PublicKeyCredential) { /* passkeys are available */ }\nif ('serviceWorker' in navigator) { /* ... */ }\n</code></pre><p></p><p>DBSC has nothing. No <code>navigator.deviceBoundSessions</code>, no constructor, no promise that rejects. The entire protocol lives in HTTP headers, and it's driven by the server, not the page. Your server says \"I'd like to bind this session to a device key\" and the browser either quietly gets on with it, or it quietly ignores you.</p><p>That design is deliberate, and it's a good thing. It's what makes DBSC safe to deploy: a browser that doesn't support it ignores the registration header and carries on with normal cookie auth, so you cannot lock anyone out by switching it on. I've made that point before and I stand by it. But it does leave you with an awkward question if you're on the other side of it, wondering whether your own browser is doing any of this.</p><p>You can't ask. You can only find out by trying...</p><p></p><h3 id=\"watching-for-the-answer\">Watching For The Answer</h3><p>So that's what the site does. When you load <a href=\"https://dbsc.dev/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">dbsc.dev</a>, the response carries a registration header, exactly as a real deployment would:</p><pre><code class=\"language-http\">HTTP/2 200\nCache-Control: no-store\nSecure-Session-Registration: (ES256); path=\"/dbsc/register\"; challenge=\"519aaae6ee95...\"\n</code></pre><p></p><p>If your browser supports DBSC, it generates a key pair, signs that challenge with the private half, and POSTs the result back to <code>/dbsc/register</code> without any involvement from the page at all. On my machine that round trip takes about 700ms. Meanwhile, the page is polling a status endpoint, waiting to see whether the registration lands.</p><p>If it lands, you get a green box. If nothing arrives within eight seconds, you get a red one — and I've been careful about what that red box says. It says <em>no registration attempt arrived in eight seconds</em>. It does not say your browser can't do DBSC, because that's not something this test can prove. Maybe an extension stripped the header. Maybe an enterprise policy turned it off. Maybe you're on a platform with no key store to bind to. The site lists all of that rather than claiming certainty it doesn't have.</p><p></p><h3 id=\"the-bit-i-actually-wanted-to-build\">The Bit I Actually Wanted To Build</h3><p>A yes/no answer is fine, but it isn't very interesting, and honestly you can usually guess. What I wanted was to see the protocol in action and make it useful for debugging.</p><p>So once your browser registers, the page shows you the whole exchange. The registration JWT your browser sent, decoded. The device public key it generated, as a JWK, with its thumbprint and its PEM. The session instructions the server sent back. A plain <code>fetch()</code> proving the bound cookie is riding your ordinary requests, which is the entire security property in one line.</p><p>One detail I enjoyed: the device key doesn't travel where you might expect. The payload of that registration JWT is almost empty.</p><pre><code class=\"language-json\">{\n \"jti\": \"mvt-nE8miIcX--li4LlmEo0bcs5m3BhqJoS2aob76Pc\"\n}\n</code></pre><p></p><p>Just the challenge, signed. The key itself is up in the JWT <em>header</em>, as a <code>jwk</code> alongside <code>\"typ\": \"dbsc+jwt\"</code>. I had it wrong in my own code until I decoded a real one from Chrome.</p><p>While I'm being precise about what the site can and can't tell you: it cannot tell you your key is in a TPM. DBSC carries no attestation, so a server sees a public key and a valid signature and that's it. Anyone claiming otherwise from server-side data alone is guessing.</p><p></p><h3 id=\"watching-a-refresh-happen-live\">Watching A Refresh Happen Live</h3><p>The refresh is the part nobody ever sees, because in production it happens to a cookie you were never looking at. It's also the part that makes DBSC work: the bound cookie is short-lived, and renewing it needs a fresh signature from the device key. Both the cookie and the challenge have to change every single time.</p><p>I wanted people to be able to watch that, so I set the bound cookie's lifetime to three minutes. Keep the tab open and you can see your device re-sign, with the old and new cookie values shown side by side. I couldn't go shorter than three minutes as I kept hitting TPM signing quota limits, so apologies for the short wait.</p><p></p><h3 id=\"why-you-might-actually-use-it\">Why You Might Actually Use It</h3><p>If you're curious, it answers the question of whether or not your browser supports DBSC in just a few seconds, and then shows you the protocol if you'd like to know more.</p><p>If you're implementing DBSC, it's a reference exchange you can point at. Here's what a real registration JWT looks like. Here's the two-phase refresh — the 403 with the challenge, then the signed retry. Here's what rotation looks like when it's working. I wrote the server side of this twice before I got the wire protocol right, and a working example would have saved me a fortnight.</p><p>If you're filing a bug, there's a copy button that gives you the whole diagnostic as text: the verdict, the decoded JWTs, the key, the timeline, and what your browser told us about itself. Rather more useful in an issue than \"DBSC doesn't work for me\".</p><p></p><h3 id=\"on-privacy\">On Privacy</h3><p>Nothing is retained. Each visit mints a throwaway identifier, and the key material and timeline are deleted within the hour. No IP logging, no analytics on your session, nothing shared. The key you see on the page is a public key your own browser generated for that one page load, and it's worthless to me.</p><p>I wanted to be clear on this, given the site's entire subject is device-bound cryptography.</p><p></p><h3 id=\"under-the-hood\">Under The Hood</h3><p>The server side is <a href=\"https://github.com/report-uri/dbsc-php?utm_source=scotthelme.co.uk\">dbsc-php</a>, the open source library I extracted from Report URI's production DBSC integration. The site is a couple of hundred lines of PHP on top of it. If you want to run DBSC on your own site and you're in the PHP world, that library carries all the wire-protocol corrections that only surface when you integrate against a real browser — including several <a href=\"https://scotthelme.co.uk/everything-i-learned-shipping-device-bound-session-credentials/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">I learned about the hard way</a> and wrote up separately.</p><p>The site is sponsored by <a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\">Report URI</a>, same as <a href=\"https://whynopasskeys.com/?utm_source=scotthelme.co.uk\">Why No Passkeys?</a>.</p><p></p><h3 id=\"client-support\">Client Support</h3><p>Chrome remains the only browser that implements DBSC. It's generally available on Windows, and <a href=\"https://scotthelme.co.uk/device-bound-session-credentials-lands-in-chrome-on-macos/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">support has since landed on macO</a>S. Firefox and Safari have nothing. That'll date quickly, which is rather the point of building a site that tests instead of a table that claims.</p><p>Go and find out: <a href=\"https://dbsc.dev/?utm_source=scotthelme.co.uk\"><strong>dbsc.dev</strong></a></p><p></p><h3 id=\"sources\">Sources</h3><ul><li><a href=\"https://w3c.github.io/webappsec-dbsc/?utm_source=scotthelme.co.uk\">Device Bound Session Credentials</a> — W3C specification</li><li><a href=\"https://developer.chrome.com/docs/web-platform/device-bound-session-credentials?utm_source=scotthelme.co.uk\">Device Bound Session Credentials</a> — Chrome for Developers</li><li><a href=\"https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?utm_source=scotthelme.co.uk\">Device Bound Session Credentials: making stolen cookies useless</a> — my earlier explainer</li><li><a href=\"https://github.com/report-uri/dbsc-php?utm_source=scotthelme.co.uk\">report-uri/dbsc-php</a> — the server library</li><li><a href=\"https://scotthelme.co.uk/everything-i-learned-shipping-device-bound-session-credentials/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Everything I Learned Shipping Device Bound Session Credentials</a> - my stories from the trenches</li></ul>",
"image": {
"url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-dev.png",
"title": null
},
"media": [
{
"url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-dev.png",
"image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-dev.png",
"title": null,
"length": null,
"type": "image",
"mimeType": null
}
],
"authors": [
{
"name": "Scott Helme",
"email": null,
"url": null
}
],
"categories": [
{
"label": "DBSC",
"term": "DBSC",
"url": null
}
]
},
{
"id": "6a7afcfd9f063f00018dac2d",
"title": "Device Bound Session Credentials lands in Chrome on macOS",
"description": "<p><a href=\"https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Device Bound Session Credentials</a> (DBSC) is Chrome's answer to session cookie theft, usually by InfoStealer malware. Instead of a cookie being a bearer token that works anywhere it's pasted, DBSC binds the session to a private key that lives in your device's hardware and</p>",
"url": "https://scotthelme.co.uk/device-bound-session-credentials-lands-in-chrome-on-macos/",
"published": "2026-08-11T14:25:04.000Z",
"updated": "2026-08-11T14:25:04.000Z",
"content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-macos.png\" alt=\"Device Bound Session Credentials lands in Chrome on macOS\"><p><a href=\"https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Device Bound Session Credentials</a> (DBSC) is Chrome's answer to session cookie theft, usually by InfoStealer malware. Instead of a cookie being a bearer token that works anywhere it's pasted, DBSC binds the session to a private key that lives in your device's hardware and can never be exported. Chrome has to prove possession of that key to be issued fresh cookies, so a stolen cookie stops working almost immediately after it leaves the device.</p><p>That protection shipped to Windows first, backed by the TPM, and it's now on macOS, backed by the Secure Enclave.</p><p></p><h2 id=\"the-timeline\">The timeline</h2><p>The Chrome Enterprise and Education release notes are the clearest official statement of platform availability:</p><blockquote><strong>Chrome 145 on Windows</strong>: Feature rolls out gradually.<br><strong>Chrome 147 on macOS</strong>: Feature rolls out gradually.</blockquote><p></p><p>Note \"rolls out gradually\" on both. This isn't a switch that flips for everyone the moment you update. It's a staged rollout controlled by Chrome's variations system (called <a href=\"https://developer.chrome.com/docs/web-platform/chrome-finch?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Finch</a>) so your browser may or may not have support for it just yet.</p><p>You can see that split in the Chromium source. In <code>net/base/features.cc</code>:</p><pre><code class=\"language-cpp\">#if BUILDFLAG(IS_WIN)\nBASE_FEATURE(kDeviceBoundSessions, base::FEATURE_ENABLED_BY_DEFAULT);\n#else\nBASE_FEATURE(kDeviceBoundSessions, base::FEATURE_DISABLED_BY_DEFAULT);\n#endif\n</code></pre><p>Windows gets DBSC by default. Every other platform, macOS included, is off by default and depends entirely on the variations seed to switch it on. If you're testing DBSC on a Mac and nothing happens like when I tested it, this is why.</p><p></p><h2 id=\"reading-your-variations-assignment\">Reading your variations assignment</h2><p><code>chrome://version</code> lists your active variations, but as hashed pairs:</p><pre><code>4e5d86a8-5e85fe73\n</code></pre><p></p><p>Those are the trial name and group name, each hashed. The algorithm is <code>base::HashFieldTrialName</code> in <code>base/metrics/metrics_hashes.cc</code>:</p><pre><code class=\"language-cpp\">uint32_t HashFieldTrialName(std::string_view name) {\n std::array<uint8_t, SHA_DIGEST_LENGTH> hash;\n ::SHA1(reinterpret_cast<const uint8_t*>(name.data()), name.size(), hash.data());\n return U32FromLittleEndian(base::span(hash).first<4>());\n}\n</code></pre><p></p><p>SHA-1 of the name, take the first four bytes, read them as a <strong>little-endian</strong> uint32. The pair is then printed <code>%x-%x</code>, lowercase, no zero padding. Here's the Python to reproduce it:</p><pre><code class=\"language-python\">import hashlib, struct\n\ndef hash_name(name):\n digest = hashlib.sha1(name.encode()).digest()[:4]\n return struct.unpack('<I', digest)[0]\n\ntrial = \"DeviceBoundSessionCredentialsMac\"\ngroup = \"Control_Post149Split_30pct\"\nprint(\"%x-%x\" % (hash_name(trial), hash_name(group)))\n# 4e5d86a8-5e85fe73\n</code></pre><p></p><p>Some useful values to search your own list for:</p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Name</th>\n<th>Hash</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Trial: <code>DeviceBoundSessionCredentialsMac</code></td>\n<td><code>4e5d86a8</code></td>\n</tr>\n<tr>\n<td>Group: <code>Enabled</code></td>\n<td><code>3f4a17df</code></td>\n</tr>\n<tr>\n<td>Group: <code>Disabled</code></td>\n<td><code>3d47f4f4</code></td>\n</tr>\n<tr>\n<td>Group: <code>Default</code></td>\n<td><code>ca7d8d80</code></td>\n</tr>\n<tr>\n<td>Group: <code>Control</code></td>\n<td><code>f23d1dea</code></td>\n</tr>\n<tr>\n<td>Group: <code>ClientSideFeatureConflict</code></td>\n<td><code>bfe70100</code></td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p></p><p>The hash is one-way, so you can only confirm names you can guess, or you can just use this URL and view them as readable text:</p><pre><code>chrome://version/?show-variations-cmd\n</code></pre><p></p><p>That prints the full variations command line with readable trial and group names, in the form <code>TrialName/GroupName</code> so you can search for <code>DeviceBoundSessionCredentialsMac</code>.</p><p></p><h2 id=\"what-a-control-group-looks-like\">What a control group looks like</h2><p>This is where it got interesting on my Mac. My assignment:</p><pre><code>DeviceBoundSessionCredentialsMac/Control_Post149Split_30pct\n</code></pre><p></p><p>A control arm of a 30% split. Control groups exist so Google can measure the treatment against a baseline, and this one isn't passive. Scanning <code>--disable-features</code> in the same output, every one of these is tagged <code><DeviceBoundSessionCredentialsMac</code>, meaning the study is what switches them off:</p><pre><code>UseUnexportableKeyServiceInBrowserProcess\nPersistDeviceBoundSessions\nUnexportableKeyDeletion\nDeviceBoundSessionsFederatedRegistration\nDeviceBoundSessionsForRestrictedSites\nEnableChromeRefreshTokenBinding\nEnableOAuthMultiloginStandardCookiesBinding\nEnableOAuthMultiloginStandardCookiesBindingForSecondaryPartitions\n</code></pre><p></p><p><code>UseUnexportableKeyServiceInBrowserProcess</code> is the important one. It's the browser-process service that mints the hardware-backed key. With it disabled, Chrome will happily parse a <code>Secure-Session-Registration</code> header, discover it has no way to create a key, and abandon registration. No request to your registration endpoint, no console warning, not even an entry in a <code>chrome://net-export</code> capture. The server sees a perfectly good offer go out and nothing come back, and that's what threw me off when I was trying to test this.</p><p>That also explains why forcing the feature flag on didn't help. In the variations command line, a feature you set yourself appears bare, with no <code><TrialName</code> suffix, so I could confirm <code>DeviceBoundSessions</code> genuinely was enabled. The feature was on; the key service beneath it was off.</p><p>If you need to override a control assignment for testing, quit Chrome completely and launch it with both:</p><pre><code class=\"language-bash\">open -a \"Google Chrome\" --args \\\n --enable-features=DeviceBoundSessions,UseUnexportableKeyServiceInBrowserProcess,PersistDeviceBoundSessions\n</code></pre><p></p><p>Two gotchas worth knowing. <code>open --args</code> is ignored entirely if Chrome is already running, so quit it first. And Chrome's own \"Relaunch\" button rebuilds its command line from scratch, discarding anything you passed.</p><p></p><h2 id=\"where-this-leaves-us\">Where this leaves us</h2><p>macOS support is here and it shipped in Chrome 147, but it's arriving gradually and a meaningful slice of users are in a control arm that switches the underlying key service off. If you're implementing DBSC and testing on a Mac, check <code>chrome://version/?show-variations-cmd</code> before you spend an evening debugging your implementation...</p><p>The good news for site operators is that none of this requires anything from you. DBSC degrades cleanly: a browser without support simply ignores the registration header, the binding is never created, and the session carries on as an ordinary cookie session. You can deploy it now and users pick up the protection as their browsers gain it. Over at <a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Report URI</a> in our <a href=\"https://scotthelme.co.uk/dbsc-beta-at-report-uri/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">beta deployment of DBSC</a>, we've now increased our sample to 10% of users and things continue to go smoothly. As we gain more confidence, we'll keep increasing until 100% of users have DBSC available, and you can see if your current session has DBSC protection on the Settings page 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/08/image.png\" class=\"kg-image\" alt=\"Device Bound Session Credentials lands in Chrome on macOS\" loading=\"lazy\" width=\"922\" height=\"413\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/image.png 922w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>The 'Bound' column shows if your session has DBSC protection enabled with more and more customers seeing this as time goes by.</p>",
"image": {
"url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-macos.png",
"title": null
},
"media": [
{
"url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-macos.png",
"image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-macos.png",
"title": null,
"length": null,
"type": "image",
"mimeType": null
}
],
"authors": [
{
"name": "Scott Helme",
"email": null,
"url": null
}
],
"categories": [
{
"label": "DBSC",
"term": "DBSC",
"url": null
},
{
"label": "macOS",
"term": "macOS",
"url": null
},
{
"label": "chrome",
"term": "chrome",
"url": null
}
]
},
{
"id": "6a6674cb3666810001632ff6",
"title": "Everything I Learned Shipping Device Bound Session Credentials",
"description": "<p>We shipped Device Bound Session Credentials at Report URI, open-sourced the server-side implementation, and then discovered a long list of things the specification doesn't prepare you for.</p><p>Some caused random logouts. One could deadlock a browser tab indefinitely. Two silently turned a device-bound session back</p>",
"url": "https://scotthelme.co.uk/everything-i-learned-shipping-device-bound-session-credentials/",
"published": "2026-08-10T16:06:11.000Z",
"updated": "2026-08-10T16:06:11.000Z",
"content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-lessons.png\" alt=\"Everything I Learned Shipping Device Bound Session Credentials\"><p>We shipped Device Bound Session Credentials at Report URI, open-sourced the server-side implementation, and then discovered a long list of things the specification doesn't prepare you for.</p><p>Some caused random logouts. One could deadlock a browser tab indefinitely. Two silently turned a device-bound session back into an ordinary bearer-token session while everything appeared to be working.</p><p>This post is the collection of those production lessons: the bugs, browser behaviours, race conditions and implementation traps I wish we'd known before we started.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/report-uri-logo.png\" class=\"kg-image\" alt=\"Everything I Learned Shipping Device Bound Session Credentials\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/08/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/report-uri-logo.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h2 id=\"what-dbsc-actually-does\">What DBSC actually does</h2><p>Briefly, because I have a full <a href=\"https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">explainer blog post on DBSC</a> that you should read:</p><p>Session cookies have one enormous weakness: they're bearer tokens. If malware on a user's machine reads the cookie out of the browser's storage and sends it to an attacker, the attacker is now that user. Every MFA prompt, every device check, every clever thing you did at login has already happened, and the cookie doesn't care. Infostealer malware has industrialised exactly this problem.</p><p>DBSC fixes it by binding the session to a private key the browser generates in hardware — a TPM, a secure enclave — and cannot export. Alongside your normal session cookie there's a second, short-lived cookie. When that cookie expires, the browser <em>defers</em> whatever request needed it, calls a refresh endpoint on your server, proves possession of the device key by signing a challenge, and gets a fresh cookie back. Then, the deferred request resumes.</p><p></p><h2 id=\"the-spec-reads-backwards\">The spec reads backwards</h2><p>Reading the specification, the shape I came away with was: registration is a two-step negotiation, and refresh is a single request. Both our tracking issue and my implementation plan were based on that. Turns out, it's the other way round.</p><p><strong>Registration is single-phase.</strong> You attach a <code>Secure-Session-Registration</code> header to an authenticated response. Chrome generates a key, signs a JWT, and POSTs it to your registration endpoint. You verify it, create the binding, and reply <code>200</code> with the bound cookie. Done. One round trip.</p><p><strong>Refresh is two-phase.</strong> The browser POSTs to your refresh endpoint with no challenge, because it doesn't have one yet. You answer <code>403</code> with a <code>Secure-Session-Challenge</code> header. The browser signs <em>that</em> and POSTs again. Now you answer <code>200</code> with a fresh cookie. Two round trips, and the <code>403</code> is the normal, healthy, everyday path, not an error.</p><p>I know I'm not alone here, because months later the author of a Node DBSC implementation opened an issue on our repo and said, unprompted:</p><blockquote>the 403-then-200 refresh took me an embarrassing amount of time to figure out</blockquote><p>If you take one thing from this post, take the fact that a <code>403</code> on your refresh endpoint is what progress looks like on the way to success.</p><p></p><h2 id=\"dont-put-the-state-in-your-session-store\">Don't put the state in your session store</h2><p>This one is worse, because it fails silently and it fails <em>closed-looking</em>.</p><p>When we first shipped, DBSC state lived where all our other session state lives: in the session, keyed off the session ID. Obvious choice, right? Every request already loads it, it expires when the session expires, the plumbing is free. Easy.</p><p>PHP sessions, and plenty of other session implementations, serialise the entire session as one blob and write the whole thing back. Last writer wins.</p><p>Now look at what the browser does immediately after login:</p><pre><code>POST /login → 200, response carries Secure-Session-Registration\n ├── GET /account/ (the navigation the user is actually doing)\n └── POST /dbsc/register (Chrome, off the back of that header)\n</code></pre><p></p><p>Those two run concurrently on the same session. <code>/account/</code> loads the session <em>before</em> registration completes, does its normal work, and writes its pre-registration snapshot back last. The binding we just carefully created got nuked in the process.</p><p>This isn't just a bug, either, it's a security bug. The enforcement gate, finding no binding, concludes there is no DBSC session here and falls back to plain cookie authentication. Which is exactly correct behaviour for a browser that doesn't support DBSC, but exactly wrong here. The user's session is now a bearer token again. No error was logged. Nothing looked broken. Our audit trail showed registrations succeeding, because they had.</p><p>The fix is to give DBSC its own storage, keyed by the session ID but written independently, so a concurrent session write can't clobber it. It's in the library's README as a warning now, phrased about as bluntly as I could manage:</p><blockquote>Report URI shipped DBSC with state in the PHP session blob; the post-login navigation races the <code>/dbsc/register</code> POST, both rewrite the whole blob last-writer-wins, the binding is clobbered, and enforcement silently no-ops — leaving exactly the stolen-cookie hole DBSC exists to close.</blockquote><p>If you're implementing this: your DBSC binding needs its own key. Not a field in an existing blob you rewrite wholesale.</p><p></p><h2 id=\"everything-rotates-or-the-browser-terminates-you\">Everything rotates, or the browser terminates you</h2><p>Three related rules, all learned the same way, all now baked into the library.</p><p><strong>Rotate the cookie value on every refresh.</strong> If you verify the refresh JWT and reply <code>200</code> with the same cookie value the browser already has, because nothing has changed, so why not, Chrome reads that as \"no refresh happened\" and terminates the session. It wants to see rotation as proof the server actually did something I guess.</p><p><strong>Rotate the challenge on every refresh too.</strong> Same reasoning. A refresh that doesn't advance the challenge hasn't advanced anything.</p><p><strong>Do not put a challenge on the registration response.</strong> This one is properly counter-intuitive: it looks like an easy optimisation to hand the browser its first challenge on the same response that creates the session, saving that first <code>403</code>. Chrome reports a Challenge Error and the session never gets going.</p><p></p><p>The reason is buried in two separate sections of the spec, and I only really understood it a month later when I was arguing about test vectors with that Node implementer. A <code>Secure-Session-Challenge</code> carries an <code>id</code> parameter naming which session it belongs to, and a challenge whose session can't be identified is silently dropped. But the registration response is the response that <em>creates</em> the session. At the moment it's parsed, there's nothing for the <code>id</code> to name. So the challenge resolves to nothing and Chrome, I guess quite reasonably, complains.</p><p>I tried it, it didn't work, I reverted it, and there is now a test in the library whose name is literally <code>register response has NO Secure-Session-Challenge (Chrome rejects it there)</code>, because I did not want anyone (including future Scott with terrible memory) to \"optimise\" it back in.</p><p>There's a fourth rule in the same family that's less about Chrome and more about arithmetic: <strong>your challenge TTL must be longer than your cookie lifetime.</strong> The browser caches a challenge. If it caches one just before the cookie expires, and the challenge TTL is shorter, the challenge is dead by the time there's a reason to use it. Our library's config constructor now refuses to build with the values the wrong way round.</p><p></p><h2 id=\"the-bug-you-cannot-see-on-localhost\">The bug you cannot see on localhost</h2><p>This is my favourite one, and it's the most transferable lesson here even if you never touch DBSC.</p><p>The bound cookie rotates on every refresh. Fine. But rotation is not instantaneous <em>from the browser's point of view</em>. The refresh is a round trip, and during that round trip the browser is still doing other things.</p><pre><code>t+0ms browser starts POST /dbsc/refresh\nt+205ms browser dispatches GET /ajax/some-widget ← carries the OLD cookie. Correctly.\nt+1235ms refresh response lands, browser stores the NEW cookie\n</code></pre><p></p><p>That widget request left the browser 205ms into a 1,235ms refresh. It carried the pre-rotation cookie value because that was, at that instant, the only value the browser had. It is a completely legitimate request from a completely legitimate session.</p><p>Our enforcement gate compared the presented cookie against the stored one, found a mismatch, and concluded: stolen cookie. Terminate the session, revoke the binding, log the user out.</p><p>We shipped that and then beta users started getting randomly logged out.</p><p>The signature in our audit trail was a successful refresh followed about a second later by an enforcement termination, with <em>no</em> refresh failure between them, which is what told us the fault was in the gate, not the refresh path. We caught it properly with a request trace showing exactly the sequence above.</p><p>Here's the part that makes it dangerous: <strong>the failure rate is proportional to latency.</strong> The window is exactly the refresh round-trip time. On a developer's loopback interface that's a couple of milliseconds and you will basically never see it. We left it running on dev for two hours before it fired even once. Behind a CDN, over a real network, it's a second or more, and it fires on almost every refresh that races an ordinary request. Which is most of them, on a busy page.</p><p>It passed every test we have, but it broke in production because production has physics.</p><p>The fix is a single-depth history: accept the immediately-previous cookie value, but only until the instant that value would itself have expired in the browser anyway. I want to draw attention to that second clause, because it's a small design point I'm quite pleased with. There is no grace constant. No \"give it five seconds and see\". The acceptance window is exactly the lifetime the browser itself would still send that value for. It's a real quantity, derived from the system and it expires on its own without anyone having to tune it.</p><p>And the security exposure is genuinely bounded too. One generation deep, expiring naturally, and a genuinely stolen cookie still can't complete a refresh without the device key, so it still hard-fails within minutes.</p><p></p><h2 id=\"never-redirect-a-dbsc-endpoint\">Never redirect a DBSC endpoint</h2><p>Now, the big one. Our DBSC endpoints inherited the standard authentication gate that sits in front of every authenticated route on our application. Sensible reuse. That gate does what every such gate does: if you're not authenticated, you get a redirect to the login page.</p><p>Consider what happens when a session finally expires while a tab sits idle:</p><ol><li>The tab wakes up and requests a page.</li><li>The bound cookie has expired, so Chrome <strong>defers</strong> that navigation and calls <code>/dbsc/refresh</code> first.</li><li>Our auth gate sees an expired session and answers the refresh with <code>302 → /login</code>.</li><li>Chrome... stops.</li></ol><p>Not \"gives up\". Not \"terminates the session and continues\". It deadlocks. The deferred navigation is never released, never times out, and never fails. Blank tab, preliminary headers, forever.</p><p>We proved it with a two-state matrix, forcing each state deliberately rather than waiting for a weekend to elapse:</p><p></p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>App session</th>\n<th>DBSC binding</th>\n<th><code>/dbsc/refresh</code> answers</th>\n<th>Chrome</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>expired</td>\n<td>present</td>\n<td><strong>302 → /login</strong></td>\n<td><strong>deadlocks</strong> — deferred navigation never resumes</td>\n</tr>\n<tr>\n<td>alive</td>\n<td>deleted</td>\n<td><strong>401</strong></td>\n<td><strong>recovers</strong> — terminates the DBSC session, navigation continues to /login</td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p></p><p>Same broken-session situation, same user experience intent, completely different outcome based purely on the status code. A <code>401</code> tells Chrome the session is over, it tears the DBSC session down, and the deferred navigation is released to do what it was always going to do and land on the login page. A <code>302</code> tells Chrome nothing it can act on, and it waits.</p><p>I feel like this is a bug so we raised it in Chromium as <a href=\"https://issues.chromium.org/issues/534027936?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">534027936</a>.</p><p>Our fix is the bit I'd encourage you to copy. The obvious patch is to add a guard: <em>if this is a DBSC endpoint and the auth gate is about to redirect, send a 401 instead.</em> </p><p>The DBSC endpoints now override the redirect mechanism itself to answer <code>401</code>. Not \"this gate doesn't redirect DBSC requests\" but \"<strong>this endpoint cannot emit a redirect at all</strong>\". Every existing gate is covered, and, more importantly, so is every gate anyone adds in the next five years without knowing any of this.</p><p></p><h2 id=\"a-challenge-mismatch-is-not-an-attack\">A challenge mismatch is not an attack</h2><p>The most important fix in the library didn't come from us. It came from a contributor in the Netherlands with production logs from his own Chrome 150 install, showing real users being logged out.</p><p>His trace, near enough:</p><pre><code>20:29:33 refresh succeeded, new challenge issued\n ── 16 minutes idle ──\n20:45:40 refresh: challenge expired → session revoked path\n20:45:41 refresh: challenge mismatch → session revoked path\n20:45:41 enforcement terminated, user logged out mid-flow\n</code></pre><p></p><p>His challenge TTL was 900 seconds. The challenge presented at 20:45:40 had been issued 967 seconds earlier. So the first refresh legitimately failed as expired, which is a benign, retriable outcome, and we handled it correctly by minting a fresh challenge and answering <code>403</code>.</p><p>One second later the browser came back. And it presented a challenge the server had <em>already rotated past</em>. That's a mismatch, and we treated a mismatch the way you'd expect: as a failed cryptographic proof. Stolen cookie. Revoke everything. Nuke it from orbit. Turns out though, that's wrong.</p><p>The signature is verified before the challenge is compared. The refresh handler verifies the JWT against the device-bound public key first. Only if that passes does it look at which challenge was signed. So <em>anything that reaches the mismatch branch has already proved possession of the device's private key.</em> It cannot be forgery as a forgery dies earlier, at the signature check, and still terminates the session exactly as it should.</p><p>This means a mismatch can only ever be one of a small set of benign situations:</p><ol><li><strong>Concurrent refreshes.</strong> Two requests fire on return from idle, a service worker fetch racing a main navigation perhaps, and both holding the same cached challenge. The first succeeds and rotates. The second is correctly signed and now stale.</li><li><strong>A lost response and a retry.</strong> The browser signed a challenge, the response never arrived, so it tries again. There is no client-side fix for this, it's just a reality of the network being unreliable.</li><li><strong>A challenge-delivery race of your own making</strong>, if like us you have more than one path that can hand the browser a challenge.</li></ol><p></p><p>So mismatch, along with missing and expired, is now a benign, retriable outcome. Mint a fresh challenge, answer <code>403</code>, let the browser try again. Bad signatures remain terminal and unchanged.</p><p>Another thing I liked was how this converges under concurrency, which the previous single-generation overlap window could not do. Three concurrent refreshes, all holding challenge <code>C</code>:</p><pre><code>A(C) → 200, cookie rotates, challenge now C2\nB(C) → benign 403, mint C3 browser now signs C3\nD(C) → benign 403, mint C4 browser now signs C4\nB′(C3) → matches the previous value → 200, cookie rotates again\nD′(C4) → C4 is now two generations back, overlap consumed → benign 403, mint C6\nD″(C6) → 200. Converged.\n</code></pre><p></p><p>Everybody gets there and nobody gets logged out. The cost is one extra round trip per concurrency event (which is a bargain compared to the cost of a support ticket). And note that this handles <em>unbounded</em> concurrency, whereas the overlap window alone only ever covered two-deep. Three simultaneous requests were enough to trip a spurious logout under the old behaviour.</p><p>The general point that I keep coming back to is: A single-use nonce sent over a lossy, concurrent transport will sometimes be presented stale. That is intrinsic to the design and not a defect in it. The bug was never that mismatches happened. The bug was terminating on an outcome that is inherent, benign, and tells you nothing about attackers. </p><p></p><h2 id=\"your-sso-logins-probably-arent-binding-at-all\">Your SSO logins probably aren't binding at all</h2><p>Chrome issues the registration POST in the `SameSite` context of the navigation that carried the registration header. That's fine for a normal login on your site, the user POSTed a form to you, the response is same-site, the POST carries your session cookie. All good.</p><p>A SAML login doesn't work like that. The user lands on your callback via a chain the IdP initiated, so the registration POST that Chrome makes off that response counts as cross-site, and a <code>Lax</code> session cookie is withheld from it. The request arrives unauthenticated and gets a <code>401</code>.</p><pre><code>22:47:20 GET /account/ 200 ← Lax rides a top-level navigation\n22:47:20 POST /dbsc/register 401 ← Lax does not ride this POST\n</code></pre><p></p><p>And it's not merely a failure, it's a <em>spent</em> failure. Chrome marks the session as having a persistent HTTP error and won't retry. Offering the header on the SSO response doesn't just fail to bind that login; it burns the only attempt you get.</p><p>The fix is to record the intent rather than act on it: mark that this login should be bound, then make the actual offer on the first document request that isn't cross-site, which is typically the very next page the user loads. We detect that with <code>Sec-Fetch-Site</code>, and treat a missing header as \"don't bother\", on the grounds that a client not sending <code>Sec-Fetch-Site</code> isn't going to register anyway.</p><p></p><h2 id=\"fail-open-at-the-edges-fail-closed-at-the-gate\">Fail open at the edges, fail closed at the gate</h2><p>DBSC has a lovely property, which is that adopting it cannot lock anyone out. A browser that doesn't support it ignores the registration header, never registers, and your gate, finding no binding, degrades to ordinary cookie authentication. Locking a current Firefox user out is <em>structurally impossible</em>. You don't need a compatibility check or a user-agent sniff; the protocol does it for you.</p><p>That's the correct behaviour, and the library goes out of its way not to break it. But it has an ugly failure mode when it meets bad data.</p><p>We had a routine that parsed a stored binding and returned <code>null</code> if it couldn't. Perfectly ordinary defensive code. Except the gate reads <code>null</code> as \"there's no binding here\", which, per the paragraph above, means \"degrade gracefully to cookie auth\".</p><p>So a <em>corrupt</em> binding didn't fail closed. It quietly downgraded a hardware-bound session to a bearer token, which is the entire thing we implemented DBSC to prevent! </p><p>It's not client-triggerable, to be clear, the realistic causes are internal: a serialiser mismatch, a truncated value, a schema skew across a deploy. But \"only happens during a deploy\" is not much comfort when the failure mode is silently disabling your session protection, and deploys are exactly when things are weird.</p><p>Now it throws, <code>null</code> means \"no record\", and only that. Present-but-unparseable is a distinct, loud, fail-closed condition. The distinction is documented in the storage interface, so anyone implementing their own backend knows which is which.</p><p>There's exactly one place we deliberately do the opposite, and I think the reasoning holds. Our \"manage your sessions\" screen shows a badge for whether each session is device-bound. If one row's binding can't be read, failing the whole page closed would mean a 500 for a page that is a <em>viewer</em>, not an enforcement point. So that badge is tri-state — bound, not bound, and unknown — and a bad read degrades to \"unknown\", surfaced as a visible alert rather than a silent \"no\".</p><p></p><h2 id=\"some-decisions-id-defend\">Some decisions I'd defend</h2><p>A few smaller calls that I think generalise.</p><p><strong>Two overlap windows, deliberately asymmetric.</strong> We keep a one-generation history for the cookie <em>across</em> a successful refresh, but we explicitly discard the previous challenge on a successful refresh. That looks inconsistent, and it isn't: the refresh <code>200</code> delivers the new challenge synchronously with the new cookie, so there's no propagation window to bridge on that path — and <em>not</em> keeping it stops a spent challenge from being replayable. There's a comment in the source that says, more or less, \"this asymmetry is intentional, do not consistency-refactor these into one,\" because I could see exactly how that tidy-up would go.</p><p><strong>No Web Crypto fallback.</strong> We were asked about supporting non-Chromium browsers with a software key, and I chose not to. The entire value of DBSC for me is the hardware-binding guarantee, and a software-bound key trades that away. A browser without DBSC degrading cleanly to plain cookie auth is expected. A browser holding a software key that your code treats as hardware-bound is not.</p><p><strong>Don't validate optional JWT claims speculatively.</strong> We verify the signature and the challenge. We deliberately do not check <code>iat</code>, <code>exp</code>, <code>typ</code>, <code>iss</code> or <code>aud</code>. The draft lists them as optional, browser emission isn't stable across versions, and our challenge TTL is already stricter than any <code>exp</code> a browser would plausibly emit. Adding checks the spec doesn't require buys you nothing, and we can tighten later if a revision mandates it.</p><p><strong>Set a content type even on empty responses.</strong> Small one. Our <code>403</code> challenge response has no body, but it declares <code>application/json</code> anyway — because in development a framework debug bar will cheerfully inject HTML into a response the browser is parsing strictly, and you will spend an hour on that. Ask me how I know.</p><p></p><h2 id=\"where-it-stands\">Where it stands</h2><p>DBSC is now in open-beta at Report URI, it is being applied to a random downsample of our users that is increasing over time. The library is <a href=\"https://github.com/report-uri/dbsc-php?utm_source=scotthelme.co.uk\">report-uri/dbsc-php</a> if you want it, or just want to read the comments, and most of what's above is in there, next to the code it explains.</p><p>DBSC is genuinely good. It closes a real hole that MFA doesn't touch, and it does it without any risk of locking users out. I'd encourage anyone running sessions at scale to look at it.</p><p>Just don't redirect the refresh endpoint 😅</p>",
"image": {
"url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-lessons.png",
"title": null
},
"media": [
{
"url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-lessons.png",
"image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/08/dbsc-lessons.png",
"title": null,
"length": null,
"type": "image",
"mimeType": null
}
],
"authors": [
{
"name": "Scott Helme",
"email": null,
"url": null
}
],
"categories": [
{
"label": "DBSC",
"term": "DBSC",
"url": null
},
{
"label": "chrome",
"term": "chrome",
"url": null
},
{
"label": "cookies",
"term": "cookies",
"url": null
},
{
"label": "PHP",
"term": "PHP",
"url": null
}
]
},
{
"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.co.uk/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/?utm_source=scotthelme.co.uk\">WICG proposal</a> (<a href=\"https://github.com/WICG/connection-allowlists?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\"><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?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\" 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.co.uk/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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\">Tranco</a> Top 1 Million (819,002 responding sites), powered by <a href=\"https://crawler.ninja/?utm_source=scotthelme.co.uk\">Crawler.Ninja</a> and <a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\"><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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\">why we need shorter lifetimes</a>, through <a href=\"https://scotthelme.co.uk/ballot-sc22-reduce-certificate-lifetimes/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\"><em>Crawler.Ninja</em></a><em> and </em><a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\"><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.co.uk/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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\"><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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Report URI</a> and we'll make it easy. <a href=\"https://scotthelme.co.uk/goodbye-feature-policy-and-hello-permissions-policy/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">blogged about it being removed</a> back in 2020.</li><li><a href=\"https://scotthelme.co.uk/what-the-floc/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\"><em>Crawler.Ninja</em></a><em> and </em><a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\"><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/?utm_source=scotthelme.co.uk\" 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.co.uk/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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\"><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?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\">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.co.uk/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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\"><strong>whynopasskeys.com</strong></a></p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://whynopasskeys.com/?utm_source=scotthelme.co.uk\"><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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\">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.co.uk/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/?utm_source=scotthelme.co.uk\"><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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\">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/?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\">Trend Micro — What Is the Instructure Canvas Breach?</a><br><a href=\"https://ailearninsights.substack.com/p/what-can-we-infer-from-canvass-technical\">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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\"><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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\">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?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\"><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.co.uk/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?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\"><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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">report-uri/dbsc-php</a><br>Source & docs: <a href=\"https://github.com/report-uri/dbsc-php?utm_source=scotthelme.co.uk\">https://github.com/report-uri/dbsc-php</a><br>The spec: <a href=\"https://github.com/w3c/webappsec-dbsc?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\"><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.co.uk/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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\"><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/?utm_source=scotthelme.co.uk\">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.co.uk/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?utm_source=scotthelme.co.uk\">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.co.uk\">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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk#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)?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">announcement</a>), and it looks like we could get it soon on <a href=\"https://chromestatus.com/feature/5140168270413824?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Device Bound Session Credentials (DBSC) | Chrome for Developers</a><br><a href=\"https://developer.chrome.com/blog/dbsc-windows-announcement?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Origin trial: Device Bound Session Credentials in Chrome | Chrome for Developers</a><br><a href=\"https://www.w3.org/TR/dbsc/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Device Bound Session Credentials (W3C draft spec)</a><br><a href=\"https://github.com/w3c/webappsec-dbsc?utm_source=scotthelme.co.uk\" 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.co.uk/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/?utm_source=scotthelme.co.uk\"><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?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">Permissions Policy</a> and <a href=\"https://report-uri.com/products/content_security_policy?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">2024</a>, <a href=\"https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/?utm_source=scotthelme.co.uk\" 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/?utm_source=scotthelme.co.uk\" 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?utm_source=scotthelme.co.uk\" 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.co.uk\" rel=\"noreferrer\">release date</a> 15th May 2026<br>Bridge Spoof PoC (same-origin script): <a href=\"https://report-uri-demo.com/passkeys/5/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">link</a><br>Bridge Spoof PoC (third-party script): <a href=\"https://report-uri-demo.com/passkeys/6/?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">link</a><br>Wrapper override PoC: <a href=\"https://report-uri-demo.com/passkeys/3/?protected&ref=scotthelme.co.uk\" 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
}
]
}
]
}