You have decided to pin a vendor script and need the mechanics: where the digest comes from, where it lives, and how a version bump happens without an outage in the middle of it.

Triage: Confirm the URL Is Actually Immutable

Pinning a URL that changes produces an outage on the vendor’s schedule. Establish immutability before generating anything.

  1. Fetch the URL and record its digest. Wait an hour, fetch again, and compare. Any difference disqualifies it.
  2. Fetch from a second region — a different network, or a proxy — and compare again. Some CDNs vary a build banner per edge.
  3. Check the URL shape. A version segment such as /3.4.1/ is a good sign; /latest/ or a query parameter is not.
  4. Confirm the response carries Access-Control-Allow-Origin, without which the browser cannot verify at all.
Four checks before the first hash Four qualifying checks for a candidate URL. Two fetches separated by an hour must agree. Two fetches from different networks must agree, which catches per-edge build banners. The URL must contain an explicit version rather than a rolling alias. And the response must carry permissive CORS headers, without which the integrity attribute is silently ignored. stable over time two fetches, one hour apart digests match stable across edges two networks digests match explicit version /3.4.1/` not `/latest/ a rolling alias will change CORS present Access-Control-Allow-Origin or the check is skipped A URL failing any of these is not a pinning candidate, and pinning it anyway schedules an outage.

Root Cause: A Hash Is a Promise About Bytes

The integrity attribute states that the resource at this URL will hash to this value. The browser enforces it by hashing the response and refusing to execute on a mismatch — so the attribute is only as good as the assumption that the bytes are fixed, and everything operational about the technique follows from managing that assumption.

Two properties of the mechanism make the workflow tractable. The browser accepts a list of digests and executes if any one matches, which is what allows a version change to be split across two deploys with no broken state in between. And the check applies only to responses the page may read, which is why CORS is a hard prerequisite rather than a detail — without it the attribute is present and inert, which is worse than absent because it looks like protection.

The failure mode that matters is not a mismatch, which is loud. It is the pin that has silently stopped applying: a crossorigin attribute dropped in a refactor, or a vendor changing their CORS headers, leaving the attribute in the markup with nothing enforcing it. That state produces no error and no warning, and it can persist indefinitely.

Where the digest lives determines the workflow Two placements for the digest. Inline in a template, it must be found and edited by hand on every version change, and it can diverge from the URL if one is updated and the other is not. In a vendor registry alongside the source URL, the category and the origin, a version change is one record edit and every consumer — the script tag, the resource hint, the policy allowlist — updates together. inline in a template edited by hand per change URL and digest can diverge and often do in the vendor registry one record, several consumers script tag, hint and policy agree a version bump is one edit The registry is the same artefact the consent categories and the CSP allowlist come from.

Resolution: Generate, Store, Roll

# Generate from the exact URL you will ship, including any query string.
curl -sS https://cdn.vendor.example/sdk/3.4.1/client.js \
  | openssl dgst -sha384 -binary | openssl base64 -A
// vendors.js — one record per vendor, consumed by templates, CSP and CI.
export const VENDORS = {
  acme: {
    src: 'https://cdn.vendor.example/sdk/3.4.1/client.js',
    // A list. During a rollover this holds two values; otherwise one.
    integrity: ['sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC'],
    origin: 'https://cdn.vendor.example',
    category: 'analytics',
  },
};
<script
  src="{{ vendor.src }}"
  integrity="{{ vendor.integrity | join(' ') }}"
  crossorigin="anonymous"
  defer></script>

The rollover is then two ordinary deploys. The first changes src to the new version and appends the new digest to the list, so both the new bytes and a revert to the old ones verify. The second, once the new version has run long enough to trust, removes the old digest. At no point is there a state in which nothing in the list matches, which is what makes the change revertible.

For scripts injected at runtime, set the property before appending, exactly as with fetchPriority:

const s = document.createElement('script');
s.src = v.src;
s.integrity = v.integrity.join(' ');   // before appendChild, not after
s.crossOrigin = 'anonymous';
document.head.appendChild(s);

Verification: Break It Deliberately

Change one character of the digest in a staging build and confirm the script does not execute and the console names the mismatch. Restore it and confirm the script runs. A build in which both cases execute has an inert attribute — check crossorigin and the response headers.

The test that proves enforcement Two staging checks. With a deliberately corrupted digest, the script must fail to execute and the console must report the expected and computed values. With the correct digest, the script must run. If the script runs in both cases the attribute is not being enforced, almost always because the crossorigin attribute is missing or the response lacks CORS headers. corrupted digest script blocked console names both values correct digest script runs no console message runs in both cases attribute is inert check crossorigin Make the corrupted case part of a recurring test, so the absence of a failure is itself verified.

Which Vendors to Pin First

Pinning everything at once is unnecessary and slows adoption. Rank candidates by two properties and start at the top: how much damage a compromised version could do, and how stable the URL is.

The highest-value pins are scripts with broad access and stable URLs — a payment SDK, a session recorder, an authentication widget. These run with full access to your document, so a hostile version is a serious event, and they are usually versioned properly because their vendors think about this. The lowest-value are cosmetic embeds behind rolling URLs, where the pin is impossible and the consequence of compromise is smaller.

Working down that list also builds the process incrementally. The first vendor is where the digest generation, the registry field, the scheduled verification and the failure policy get established; every subsequent one is a single record. Teams that try to pin twelve vendors in one change usually discover that four of the URLs are not immutable, and abandon the whole exercise rather than the four.

Common Pitfalls

  • Omitting crossorigin. The attribute is present, the check never runs, and nothing reports it.

  • Pinning a rolling URL. The first vendor deploy takes your script offline, and the fastest fix under pressure is to delete the attribute permanently.

  • Hand-editing digests in templates. The URL and the digest diverge the first time someone updates one of them.

  • Setting integrity after appendChild. The request is already in flight; the property has no effect.

  • Generating the digest from a local copy. Hash what the URL returns, not what is in your repository. A vendor’s CDN may serve a differently minified build from the artefact you downloaded once, and the two will not agree.

  • Forgetting the resource hints. A preload for the same URL must carry the same integrity value, or it occupies a different cache entry and the browser fetches the script twice.


Frequently Asked Questions

Which hash algorithm should we use?

sha384 is the sensible default: it is supported everywhere the attribute is, and it is comfortably strong for this purpose. sha256 is equally acceptable and produces a shorter attribute; sha512 is available and adds length without adding meaningful security for a resource whose contents are public.

What matters more than the choice is consistency. Generating some digests with one algorithm and some with another makes the registry harder to verify programmatically, and a scheduled job comparing digests has to know which algorithm produced each. Pick one, record it in the registry, and use it everywhere.

Can the same approach cover stylesheets?

Yes — integrity applies to <link rel="stylesheet"> as well, with the same CORS requirement and the same behaviour on mismatch, which for a stylesheet means the page renders unstyled. That consequence is usually more visible to a visitor than a missing script, so the failure policy deserves more thought.

Third-party stylesheets are worth pinning for the same reason as scripts. A compromised stylesheet can reposition and disguise interface elements, overlay content, and exfiltrate limited information through selective resource loading — less powerful than script execution, and far from harmless.

What if the vendor serves different bytes per browser?

Then the URL cannot be pinned, and the two-network check above is designed to catch it. Some vendors serve differentiated builds by user agent from a single URL, which is invisible until you compare digests from two clients rather than two networks.

Ask for a build-specific URL. Most vendors that do this also publish per-build paths for exactly this reason, and where they do not, the request is a reasonable one to make. Until then, record the vendor as unpinnable in the inventory rather than pinning the digest your own browser happened to receive.

How do we handle a vendor with dozens of files?

Pin the entry points rather than the whole tree. A vendor shipping fifty modules typically has one or two files your page references directly, and everything else is loaded by those — so pinning the entry gives you a verified root, and 'strict-dynamic' in your policy then extends trust to what it loads without a digest for each.

That is a smaller guarantee than pinning everything, and it is worth being honest about in the inventory: you have verified the loader, not the payload. For a vendor where that distinction matters, self-hosting the whole bundle is the stronger option, because then every file passes through your own pipeline and the question of pinning individual modules disappears.

Should the digest be committed or generated at build time?

Committed. A digest generated at build time from a live fetch is not a pin at all — it records whatever the vendor happened to be serving when the build ran, so a compromised version is faithfully hashed and shipped as approved. The whole point is that a human looked at a specific artefact and recorded its identity.

Generate it once, by hand or by a tool, and commit the value alongside the URL. Let the scheduled verification job compare the live resource against the committed value and open a pull request when they differ, so the change is proposed automatically and accepted deliberately.


Up: Subresource Integrity and the Vendor Supply Chain