Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

The Hash Rotated: Our Own CSP Names a Different Inline Script Than It Did on 8 November

I work on OnePassApps, the team behind app.onepassapps.com. Every number below was measured on today's build, 11 November 2026, over plain HTTPS from outside the browser - no cookies, no JavaScript, one region. Our own

I work on OnePassApps, the team behind app.onepassapps.com. Every number below was measured on today's build, 11 November 2026, over plain HTTPS from outside the browser - no cookies, no JavaScript, one region.

Our own pages carry a Content-Security-Policy that is one value, 1,199 characters long, and byte-identical on all 56 URLs in the sitemap. It has 17 directives, and exactly one of them carries a SHA-256 hash.

Eight days ago we published what that hash named. Three days later the same directive carries a different hash, the old one now matches nothing we serve, and the script it names does something the old one did not do.

The pin moved

On 8 November our policy carried:

sha256-Uk96zwJ2Cg3zp4L5PIIPtLI/vzkWqOvaXew7SqyYAsE=

Today it carries:

sha256-RgvTaO1F939DXz4kXEKDZSCEE3C0AUNM5rqv2ExtazQ=

That is not drift in the policy. The script it names was rewritten, and the hash was rewritten with it. I pulled every inline script block out of all 56 documents, hashed each one exactly as the server sent it, and compared the digests:

  • 89 inline script blocks in total across the 56 pages.
  • 33 of them are executable; 56 are JSON-LD data blocks, one per page, and each of those 56 hashes to its own digest.
  • 57 distinct digests in all: the 56 JSON-LD blocks and one executable script that is byte-identical everywhere it appears.
  • The executable one is 1,169 bytes today. Our 8 November post recorded the same script at 1,316 bytes.
  • The old hash matches 0 of the 89 blocks served today.

So the policy and the script are updated together. When the theme bootstrap changed, the hash in the header changed with it, and the pin still lands on the bytes we actually serve.

The script changed what it does, not only its size

The block whose digest matches is the theme bootstrap: the small script that reads the saved colour preference out of local storage and applies it before the page paints, and that pins the colour preset to Graphite.

On 8 November the version we served overwrote a stored light or system preference with dark. Today's version does the opposite. It reads the stored value and honours it:

  • stored light gives light;
  • stored system follows the operating system preference, and the script asks for that preference explicitly;
  • anything else, or an unreadable local storage, falls back to dark.

The colour preset is still pinned to Graphite either way, and the legacy accent key is still cleared. That behaviour change is the reason the hash had to move. A SHA-256 pin is a promise about exact bytes: one byte different and the browser refuses the script. The header could not have kept the old hash and the site could not have kept the old script.

The same 33/23 split, three days on

The guide pages still ship no JavaScript of ours - 23 of 56, the same split, and we measured that fleet properly in the guide-fleet post. Nothing about it changed: all 23 pages under /guides reference zero script files of ours and load only the two stylesheets, and the 33 pages outside /guides load the shell fallback and the main index bundle.

What the split does now is show the pin's reach. The pages carrying the theme script are exactly the pages that run JavaScript at all, and the pages that run none are exactly the pages that ship no executable inline script. The policy, meanwhile, is identical on all 56 - the same rule travels to 23 pages where nothing can match it. A hash that is present where nothing matches it is harmless, the rule simply never fires, but it means the policy is written per site while the pages it applies to are not uniform.

17 host names, and 14 of them are in no file we serve

The other half of the policy is the host allow-list: 17 distinct host names across four directives.

  • script-src: 4 names - the Clerk hosts and the Cloudflare challenge host.
  • frame-src: the same 4 names, so the sign-in frame and the sign-in script are allowed from the same place.
  • img-src: 8 names - the Clerk image host, the media hosts the two hosted apps read from, and the Wikimedia pair.
  • connect-src: 9 names - both API hosts, the Clerk hosts, the Clerk image host and the three Sentry ingest endpoints.

Our 4 November post on the response headers named that allow-list in prose. What is new is where the names live. None of the 17 appears in the served HTML of any of the 56 pages - the HTML names no host at all. Only 3 of the 17 appear literally in the JavaScript we ship: our own API host, in the shared app-shell bundle and in the main index bundle, and two of the Sentry ingest hosts, one in a lazily-loaded chunk and one in the main index bundle. The other 14 names are in no file we scanned, and we scanned 84 - the 7 assets the pages reference plus the 77 lazily-loaded chunks they point at.

Those 14 are permission rather than code. The policy says what a page may reach at runtime; we did not open a browser, so we are not claiming when or whether any of them is fetched.

What we did not do

  • We did not open a browser, so we are not claiming what a browser does with a hash at runtime.
  • We did not push any route to its ceiling.
  • The 84 files we scanned are the ones the pages reference and the chunks they point at; we did not crawl the rest of the origin for others.
  • One build, one day, one region, plain HTTPS, no cookies, no JavaScript.

If you want to see the measurements for yourself, the home page is here: the OnePassApps home page

Every app and studio has a plain-language guide with its limits and its tool list: the OnePassApps guides

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.