DEV Community

Cover image for Subresource Integrity & CDN Script Pinning: Catching Silent Payload Swaps
Muhammad Ali Mughal
Muhammad Ali Mughal

Posted on

Subresource Integrity & CDN Script Pinning: Catching Silent Payload Swaps

Your checkout page loads one script tag from a public CDN. You reviewed that library two years ago. Since then the file at that URL may have changed a dozen times, or the domain itself may have changed owners, and your page would happily execute whatever came back. In June 2024 that is exactly what happened to sites embedding polyfill.io: the domain changed hands and started serving injected code to some visitors. No deploy on their side, no diff in their repo, no alert.

I wrote this with help from AI tools and checked every command, attribute and header myself. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET, and I build PixelValut, a set of browser-side developer tools. This is Part 3 of the series Client Trust & Agent Hijack Defense. Part 1 covered prompt injection in fetched docs and Part 2 covered tool-calling attack surfaces. Both were about content you did not write steering your software. A third-party script is the most direct form of that problem: it runs with your page's full privileges.

๐Ÿ“š Series Roadmap โ€” Client Trust & Agent Hijack Defense

  • Part 1: Prompt Injection via Docs Your Tools Fetch
  • Part 2: WebMCP & Tool-Calling Attack Surfaces
  • Part 3 (This Post): Subresource Integrity & CDN Script Pinning: Catching Silent Payload Swaps
  • Part 4: CSP, Trusted Types & Residual XSS: Reading Reports Like a Forensics Log
  • Part 5: Extension & npm Postinstall Forensics: Homoglyphs, Base64 & Unexpected Network
  • Part 6: Browser Secrets That Leak: Tokens, Storage Quotas & Exfil Paths

Related earlier posts: Deobfuscating Malicious npm & Browser Payloads ยท Subdomain Takeover & Dangling DNS Reconnaissance ยท Zero-Trust Reverse Proxy Hardening in Nginx

How does Subresource Integrity stop a swapped CDN script?

Direct answer: Subresource Integrity adds a cryptographic hash of the exact file you reviewed to the integrity attribute of a script or stylesheet tag. The browser hashes the downloaded bytes and refuses to execute them if they do not match. Pin exact versions, use SHA-384, add crossorigin="anonymous", and monitor the URL so you learn about swaps before users do.

Row of sealed glass vials on a lab bench, one subtly different, AI-generated

What SRI actually checks

An SRI-protected tag looks like this:

<script
  src="https://cdn.jsdelivr.net/npm/dayjs@1.11.13/dayjs.min.js"
  integrity="sha384-DpVxUeeBWjUvUV1czyIHJAjh+jYUZFu2lLakbdua5vbwOrBGi1UgaKCHjTC+x3Ky"
  crossorigin="anonymous"
  referrerpolicy="no-referrer"></script>
Enter fullscreen mode Exit fullscreen mode

The rules that matter in practice:

  • Algorithms: browsers accept sha256, sha384 and sha512. SHA-384 is the common default and what most CDNs publish.
  • Format: the value is algo- plus the base64 encoding of the raw binary digest. A hex digest will never match.
  • Multiple hashes: you can list several values separated by spaces. The browser picks the strongest algorithm present and accepts the file if it matches any hash of that algorithm. This is how you allow a planned upgrade window.
  • Encoding: the hash covers the response body after content decoding, so gzip or Brotli at the CDN does not break it.
  • Failure: on mismatch the resource is blocked, the element's error event fires, and the console logs an integrity error. Nothing is reported to your server unless you add that yourself.

Generating hashes correctly

Always hash the exact bytes the CDN serves, not a file you built locally and assume is identical.

# Hash the file the CDN actually serves
curl -sSL "https://cdn.jsdelivr.net/npm/dayjs@1.11.13/dayjs.min.js" -o dayjs.min.js
echo "sha384-$(openssl dgst -sha384 -binary dayjs.min.js | openssl base64 -A)"

# Same thing with shasum + xxd (handy on macOS)
echo "sha384-$(shasum -b -a 384 dayjs.min.js | awk '{print $1}' | xxd -r -p | base64)"

# Self-hosted bundle produced by your build
for f in dist/assets/*.js; do
  printf '%s sha384-%s\n' "$f" "$(openssl dgst -sha384 -binary "$f" | openssl base64 -A)"
done
Enter fullscreen mode Exit fullscreen mode

The -binary flag is the detail people miss. Without it, openssl dgst prints a hex string, and base64-encoding that text gives a value that looks valid but never matches. If you are checking a hash quickly in the browser, the PixelValut hash generator supports SHA-384 and SHA-512; just make sure you compare base64 output of the digest, not hex.

The crossorigin attribute is not optional

For a cross-origin script, the browser can only verify integrity if the response is fetched in CORS mode. That means two things must be true:

  1. The tag has crossorigin="anonymous" (or use-credentials, which you almost never want for a public CDN).
  2. The CDN responds with an Access-Control-Allow-Origin header that permits your origin, usually *.

If either is missing, the integrity check fails and the script is blocked, even when the hash is correct. Major public CDNs such as jsDelivr, cdnjs and unpkg send the header. A private bucket or a misconfigured asset domain often does not. Check the response headers before you ship:

curl -sI -H "Origin: https://shop.example" \
  "https://assets.example-cdn.com/widget/2.4.1/widget.min.js" \
  | grep -iE '^(HTTP|access-control-allow-origin|content-type|cache-control|etag)'
Enter fullscreen mode Exit fullscreen mode

For a quick visual check of status codes, redirects and headers from a browser tab, the HTTP status checker shows the redirect chain too. Redirects matter: an asset URL that suddenly 301s to a different host is an early sign that something upstream changed.

Pinning: SRI only works on immutable URLs

SRI and floating versions do not mix. If the URL is /npm/lib@latest or /lib/3/lib.js, the bytes will change on the next release, the hash will stop matching, and your feature breaks. Teams then delete the integrity attribute "temporarily" and never put it back.

URL style Example Safe with SRI? Notes
Exact version lib@4.2.1/dist/lib.min.js โœ… The normal target for SRI
Major/minor range lib@4/dist/lib.min.js โŒ Breaks on every release
latest tag lib@latest โŒ Never pin a hash to this
Content-hashed self-hosted /assets/app.3f9a1c.js โœ… Ideal; generate hashes at build
Vendor SDK that must stay live payment or analytics loaders โš ๏ธ Vendor changes it on purpose; SRI not possible
User-agent varied responses some font or polyfill services โŒ Different bytes per browser cannot share one hash

The last two rows are the uncomfortable ones. Some vendors, payment SDKs included, explicitly tell you to load their script from their domain without self-hosting, because they push security fixes to it. You cannot SRI those. For them, the controls are a tight Content Security Policy that allows only that exact host, keeping them off pages that do not need them, and isolating them in a sandboxed iframe where the integration allows it.

The require-sri-for trap

Older articles recommend a CSP directive called require-sri-for script style to force every script to carry an integrity attribute. Do not rely on it. It lived in a draft spec, was only ever available behind flags, and has been removed from the specification and from browsers. A policy containing it is simply ignored for that directive.

The newer mechanism is the Integrity-Policy response header (with an Integrity-Policy-Report-Only variant), which lets you require integrity metadata for scripts and send violation reports. Support has landed in recent Chromium releases; check current browser support before depending on it, and keep the report-only form running first:

Integrity-Policy-Report-Only: blocked-destinations=(script), endpoints=(integrity-endpoint)
Reporting-Endpoints: integrity-endpoint="https://shop.example/reports/integrity"
Enter fullscreen mode Exit fullscreen mode

Reporting failures yourself

Because SRI failures are silent to your backend, add a tiny beacon on the elements you care about:

<script>
  function sriFail(el) {
    navigator.sendBeacon('/reports/sri', JSON.stringify({
      src: el.src || el.href, page: location.pathname, ts: Date.now()
    }));
  }
</script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.13/dayjs.min.js"
        integrity="sha384-DpVxUeeBWjUvUV1czyIHJAjh+jYUZFu2lLakbdua5vbwOrBGi1UgaKCHjTC+x3Ky"
        crossorigin="anonymous"
        onerror="sriFail(this)"></script>
Enter fullscreen mode Exit fullscreen mode

The error event also fires for network failures, so treat a spike as "investigate," not "confirmed attack." For scripts you insert dynamically, set script.integrity and script.crossOrigin = 'anonymous' before appending the element.

Monitoring the URL, not just the page

SRI protects users at load time. You also want to know when the upstream file changes, before a user's browser blocks it. A scheduled job that refetches each pinned URL and compares the digest is enough:

#!/usr/bin/env bash
# sri-watch.sh: lines in pins.txt look like "URL sha384-HASH"
set -euo pipefail
while read -r url expected; do
  actual="sha384-$(curl -fsSL "$url" | openssl dgst -sha384 -binary | openssl base64 -A)"
  [[ "$actual" == "$expected" ]] || echo "MISMATCH $url expected=$expected got=$actual"
done < pins.txt
Enter fullscreen mode Exit fullscreen mode

Run it from CI or a cron job and alert on any MISMATCH line. An exact-version file on a reputable CDN should never change. If it does, that is a security event, not a cache quirk.

Diagnostic checklist

  • [ ] Every third-party <script> and <link rel="stylesheet"> is inventoried with its owner.
  • [ ] All CDN URLs pin an exact version, never latest or a range.
  • [ ] Each pinned tag has integrity="sha384-..." generated from the served bytes.
  • [ ] Each cross-origin tag has crossorigin="anonymous" and the CDN returns Access-Control-Allow-Origin.
  • [ ] Build output gets integrity attributes automatically (bundler plugin or post-build script).
  • [ ] No policy depends on require-sri-for.
  • [ ] Scripts that cannot use SRI are restricted by CSP host allowlists and kept off sensitive pages.
  • [ ] SRI load failures beacon to a reporting endpoint.
  • [ ] A scheduled job re-hashes pinned URLs and alerts on mismatch.
  • [ ] Asset hosts are checked for unexpected redirects and expiring domains.

FAQ

Is SHA-256 good enough for SRI?

It is cryptographically fine. SHA-384 is the conventional default and what most CDNs publish, so using it avoids confusion.

Does SRI work for images, fonts or fetch requests?

In practice browsers enforce it on scripts and stylesheets (and modulepreload links). fetch() accepts an integrity option too. Images and fonts are not covered.

Will SRI break when the CDN recompresses files?

No. The hash is computed over the decoded body, so transfer encoding changes do not matter. Minifier or content changes do.

What about ES modules loaded with import?

Top-level module scripts take integrity like classic scripts. Statically imported dependencies can be pinned through the integrity section of an import map in browsers that support it.

Should I self-host instead of using a CDN?

Self-hosting with content-hashed filenames removes the third-party trust problem for those files. You still want SRI if assets are served from a separate domain or CDN you do not fully control.

Can an attacker who controls the CDN also change my integrity attribute?

Not unless they also control your HTML. That separation is the whole point: the hash lives in markup you serve.

Next in this series, Part 4 covers CSP, Trusted Types and residual XSS, and how to read violation reports like a forensics log.


Follow & Connect

Top comments (0)