Script integrity

JavaScript integrity monitoring

Every script tag is a promise that the file at that address is the file you meant. The address rarely changes. The file behind it can change at any time, for any visitor, and the page looks the same when it does.

Browsers can now report a hash of each script they run. This is the part of client-side security that asks what the code was, not only where it came from.

What the problem is

A script can change three ways without its URL moving. A vendor ships a new version to the same path, usually fine and occasionally how a compromise arrives. A CDN or a domain changes hands and serves something else. Or a file on your own origin is modified, which no allowlist catches, because it is still your origin.

Subresource Integrity covers all three. An integrity attribute pins a tag to a hash, and the browser refuses a file that does not match. It only protects tags that carry one, and most third-party tags cannot, because the vendor ships to the same URL on purpose. That leaves monitoring: knowing which bytes ran, and noticing when they differ from the bytes you reviewed.

What the report looks like

Add 'report-sha256' to the directive that governs your script tags and give the policy a report-to endpoint. Chrome and Edge then send one report for each script they fetch on each page load. The keyword allows and blocks nothing.

A checkout page loading jQuery from a CDN, reported by Chrome.
{
  "type": "csp-hash",
  "body": {
    "documentURL": "https://shop.example.com/checkout/payment",
    "subresourceURL": "https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js",
    "hash": "sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=",
    "type": "subresource",
    "destination": "script"
  }
}

How it appears in HeaderHawk

https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js

  • hash is the SHA-256 of the bytes the browser received, after every CDN and proxy on the way. It is the same digest jsDelivr publishes for this file, so HeaderHawk names it: jquery 3.7.1, /dist/jquery.min.js.
  • The inventory lists the script by its URL without the query string, so a cache-busting parameter is not a new script, and every hash the URL has served sits under it.
  • A second hash under a familiar URL means the script's contents changed. The name, the pinned versions and the page it ran on help you decide whether that is a release or a problem.
  • An empty hash means the script was fetched cross-origin without CORS, so there were no readable bytes to compare.

Field by field: the script-src reference.

What HeaderHawk does with it

On the Team plan and above, the script inventory lists every hash by origin and URL and names the library and version where the bytes are a published npm file. Your own bundles match nothing and read as unrecognised, correctly. The same view measures Subresource Integrity coverage: which scripts browsers load without an integrity attribute, and the attribute to paste where one version was seen.

On the Professional and Business plans, a payment-page script can be authorized with the hashes you reviewed pinned. A version you did not pin then raises an alert, once browsers on two different networks have reported it or the page's daily scan has fetched it. HeaderHawk also fetches each new version once and keeps it if the bytes match what the browser reported, so you can open the file your decision was about months later. It checks each named version against published advisories every day.

Where CSP alone is not enough

Hash reporting answers "what ran" better than anything browsers offered before, but only for some browsers, for scripts they could read, on the loads they reported.

  • Only Chrome and Edge send hash reports, from Chrome 133. Firefox does not, and Safari's support is still being built, so the inventory is what Chrome and Edge visitors fetched.
  • A script fetched cross-origin without CORS reports an empty hash. It appears in the inventory marked as not hashed, which is the prompt to add crossorigin to the tag, and until then nothing about its contents is recorded. On a payment page, the daily scan fetches and hashes it anyway.
  • A cached script reports on the first load only, inline scripts send nothing, and repeat sightings are sampled. A first sighting skips the sample, but over quota or over a site's daily hash ceiling a new hash waits for its next sighting.
  • A new hash is not a verdict. A script that varies by browser, like the polyfill service, has many hashes by design. Alerts about new and changed scripts cover payment pages only, on the Professional and Business plans, and a script authorized for any content alerts nobody when it changes, by your choice.
  • Reporting is the evidence, not the control. An integrity attribute on the tag is what stops a changed file running, and HeaderHawk never blocks anything, so a rejected or changed script runs until your site or your policy stops it.
  • A copy is kept only when the file could be fetched unchanged after it appeared. A payload served for an hour and pulled usually cannot be, and is recorded as a mismatch.

Where to go next

  • The hash generator fetches a script URL, hashes what the server sent and says whether a browser could integrity-check it, the first step to an integrity attribute on the tag.
  • Third-party scripts covers the other half: a trusted script loading a host you never approved.
  • PCI DSS 4.0 maps script integrity to 6.4.3 and 11.6.1 on payment pages.
  • The script hash reporting post covers the keyword and the report in depth.

Find out what your pages ran

Add one keyword to your policy. The script inventory starts on the Team plan.