Web Platform · Web Security Model
Subresource Integrity and the Trust Chain for Third-Party Scripts
How the integrity attribute verifies fetched bytes, why cross-origin scripts need CORS for the check to run, and what hash pinning does and does not protect against.
A page that loads a script from a CDN is trusting that CDN, the CDN’s own dependencies, and every network path between them. Subresource integrity is the one browser mechanism that narrows that trust from a party to specific bytes: the element declares a cryptographic digest of the resource it expects, and the browser refuses to execute or apply anything that does not match. The guarantee is exact but narrow, and the narrowness is where deployments go wrong.
Where the check happens in the fetch sequence
The integrity attribute accepts a space-separated set of metadata values, each of the form <algorithm>-<base64 digest> with sha256, sha384, or sha512. The browser fetches the resource, computes the digest of the response body, and compares. A resource whose bytes match a listed digest is allowed. When the list mixes algorithms, only the strongest algorithm present is used for verification, so a sha256 value does not weaken a sha384 value in the same attribute.
The check applies before the resource is used. A <script> whose digest mismatches does not execute and fires an error event on the element; a <link rel="stylesheet"> does not apply. The failure is not recoverable from the page’s perspective beyond the error event, which is the intended behaviour: there is no path by which a modified response is used with a warning.
Metadata is also honoured on <link rel="preload"> and rel="modulepreload", where it causes the preloaded response to be verified before it is handed to the consumer. The digest is computed over the response body, independent of the URL, so the same bytes served from a mirror or a different host satisfy the same digest. That property is what makes mirrors and multiple CDN fallbacks compatible with pinning.
A subtlety that catches people: integrity metadata is per-request, not per-URL, and it does not survive redirects to a different representation. A CDN that serves a different minified build to different clients, or that injects anything into JavaScript at the edge, produces bytes that do not match a hash computed against the origin build. Edge transforms and integrity pinning are mutually exclusive.
Cross-origin loads need CORS or the digest never gets verified
For a cross-origin subresource, the fetch has to be made in CORS mode for SRI to apply. That means the element carries crossorigin, and the server responds with Access-Control-Allow-Origin for the embedding origin. Without both, the response is opaque to the document, the browser has no verified bytes to hash, and the integrity check cannot succeed.
<script
src="https://cdn.example.net/lib/analytics-4.2.1.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"
defer></script>
crossorigin="anonymous" sends the request without credentials and succeeds only if the response allows the origin or *. crossorigin="use-credentials" sends cookies and requires an explicit origin in Access-Control-Allow-Origin plus Access-Control-Allow-Credentials: true. For a public library on a CDN, the anonymous form is right, and it also keeps the site’s cookies away from the CDN — an independent benefit, because a classic script tag without crossorigin does send them, while a module script’s default credentials mode keeps them back but requires the CDN to send Access-Control-Allow-Origin.
Module scripts complicate the picture. Every import in a module graph is a CORS request by definition, so module code from a CDN always needs Access-Control-Allow-Origin. Integrity on the entry <script type="module"> covers that one file; the modules it imports are fetched by the module loader and are not covered by the entry’s digest. Pinning a module graph therefore requires either bundling the graph into one file or pinning each import through a mechanism that can attach metadata to the specifier — which the bare import syntax does not offer. This is a real limitation, not a configuration problem, and it is the reason integrity pinning is most practical for single-file classic scripts and stylesheets.
Pinning hashes changes how a release works
The hash lives in the HTML, so any change to the script requires a change to every page that references it, deployed atomically. A build that emits app.[contenthash].js already has the content hash; the build can compute the base64 digest from the emitted file and substitute it into the template that renders the tag. The substitution must happen after minification and after any transformation that alters bytes, including newline normalisation on Windows checkouts.
Third-party libraries without a build step require a manual update: bump the version, fetch the file, compute the digest, update the template, deploy. If the vendor publishes a new version at the same URL — a common practice for “latest” endpoints — the pinned digest breaks the moment they publish, so pinned URLs should always be version-specific.
The operational cost is the trade. Unpinned loads get security fixes automatically and silently; pinned loads get them only when someone updates the hash. A pin that is set once and never revisited converts a supply-chain risk into a stale-dependency risk. Neither is free, and the decision belongs with whoever owns the dependency update process.
SRI also interacts with CSP hashing. When a policy lists 'sha384-<digest>' as a source for scripts, an external script matches only if its integrity attribute carries the same digest, which means the CSP and the element must be generated from the same value. Inline scripts have no such requirement but need the digest of the exact text, whitespace included. Both mechanisms are described together in CSP source lists, nonces, and trust propagation.
The part integrity does not cover
The digest proves that the bytes are the bytes the page author chose. It says nothing about whether those bytes are safe. A reviewed, pinned library is still a library that can read the DOM, read document.cookie for non-HttpOnly cookies, read form field values as the user types, issue requests with the user’s credentials, and exfiltrate anything it can reach. Pinning a malicious or compromised-at-source library pins the compromise.
Nothing in SRI protects a script from being created by other script. A trusted loader that inserts a <script> element without integrity metadata bypasses the check entirely, because the check only applies to elements that carry metadata. An injected script — from an XSS, or from a tag manager configured to run custom HTML — has full DOM access and can add any script it likes. SRI is a defence against a compromised CDN or a tampered response in transit; it is not a defence against injection, and CSP’s nonce and strict-dynamic model is the mechanism aimed at that; CSP source lists, nonces, and trust propagation explains how the policy decides which inserted scripts may run.
There is also no protection against a script that is trusted and then behaves differently based on remote configuration. A tag manager pin is meaningless in practice: the pinned bootstrap loads whatever tags are configured at runtime. The bootstrap’s bytes are verified; its behaviour is determined by a remote manifest that changes without any hash changing.
Choosing the arrangement that matches the dependency
Four arrangements cover most cases, and they differ in what is verified where.
A bundled dependency is copied into the application’s build, served from the application’s origin, and covered by the same release process as first-party code. The third party has no runtime presence; updates arrive when the dependency is bumped. This is the strongest arrangement and the most work to maintain.
A pinned third-party script is loaded from the vendor’s CDN with crossorigin and a versioned URL plus a digest. It verifies bytes but the vendor still executes, with full page privileges, and the URL is a single point of failure at page load.
A proxied third-party script is fetched by the application’s own server and re-served from the application’s origin. The bytes can be pinned server-side, the URL is first-party, and caching is under the application’s control — at the cost of proxying availability and of taking on the vendor’s traffic. Serving it from a path that is not covered by the application’s cookies and is not on a subdomain that shares cookie scope limits the blast radius.
An isolated third-party widget runs in a sandboxed iframe on its own origin and communicates through postMessage with exact origin checks. This is the only arrangement in which the third party cannot read the host page’s DOM or cookies, and it requires the widget to be designed for it. The frame boundary rules are in sandboxed frames and the postMessage trust boundary.
The decision hinges on one question: does the code need to touch this document? Analytics and scripts that only need to send beacons can often be reduced to a small first-party shim. Anything that must render inside the page is a frame. Anything that must run as a script with DOM access is a bundle or a pin, and the residual risk is the vendor’s behaviour, which no digest can constrain.
References
- Subresource Integrity — W3C
- Fetch Standard — Integrity metadata — WHATWG
- HTML Standard — Link types: stylesheet — WHATWG