Subresource Integrity for Third-Party Assets

In 2024, a widely embedded polyfill CDN changed ownership and began serving malicious code to the sites that loaded its script. Every one of those sites had done nothing wrong except trust that a URL would keep returning the same file. Subresource Integrity (SRI) removes that trust: you publish the hash of the file you expect, and the browser refuses to run anything else.

For static sites, SRI is one of two answers to the same question, and often the second-best one. This guide explains when SRI is the right tool, how to generate integrity hashes at build time so they never drift, and when self-hosting the file removes the need entirely. It is part of Security Headers and Hardening for Static Sites.

Prerequisites

  • A list of every external <script> and <link rel="stylesheet"> your built pages load.
  • For each, whether its URL is versioned and immutable (/npm/katex@0.16.11/dist/katex.min.css) or mutable (/latest/, /v1/loader.js).
  • A build step where hashes can be computed — a post-build script works for any generator.

How SRI Works

<link rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css"
      integrity="sha384-nB0miv6/jRmo5UMMR1wu3Gz6NLsoTkbqJghGIsx//Rlm+ZU03BU6SQNC66uf4l5+"
      crossorigin="anonymous">

The browser fetches the file with CORS (hence crossorigin="anonymous"), computes its SHA-384 digest, and compares it with the integrity value. On a match, the stylesheet applies; on a mismatch, it is discarded and a console error is logged. A Content-Security-Policy can additionally require SRI on all scripts with require-sri-for in some browsers, but the attribute itself is what does the work everywhere.

Integrity check on a third-party script The page requests a versioned library from a CDN with an integrity hash. The browser hashes the downloaded bytes. If the CDN returns the original file, the hashes match and the script runs. If the file was tampered with on the CDN, the hashes differ and the script is blocked, so the page loses one feature instead of running attacker code. The browser checks the bytes before running them page integrity="sha384-…" CDN returns bytes hash bytes SHA-384 match → run original file differ → block tampered file A blocked script degrades one feature; an unchecked tampered script compromises every page
SRI converts a supply-chain compromise at the CDN into a failed load, which is exactly the trade you want.

Step 1: Classify Every External Asset

SRI only works when the bytes at a URL never change. Sort each external asset into one of three groups:

AssetURL typeAction
KaTeX CSS from jsDelivr, pinned versionimmutableadd SRI (or self-host)
Chart.js from a CDN, pinned versionimmutableself-host at build time
Analytics loader (/js/script.js)mutable, vendor-updatedcannot use SRI; restrict with CSP
Video embed playermutable, in an iframeisolate in iframe; CSP frame-src
Google Fonts CSSmutable, varies per browserself-host fonts instead

The mutable ones cannot use SRI at all: the vendor updates the file behind the same URL, and every update would break the page. For those, CSP limits where the script can come from and where it can send data — see Writing a Content Security Policy for a Static Site.

Step 2: Prefer Self-Hosting

For immutable third-party files, ask first whether they need to be external at all. On a static site, installing the library from npm and bundling or copying it into the build output has four advantages over a CDN with SRI: no extra origin (no DNS, TCP or TLS setup), no dependency on the CDN's uptime, the file is covered by the site's own cache headers and CSP 'self', and the lockfile's integrity hash pins it at install time.

// astro.config.mjs — copy a library's dist files into the build
import { viteStaticCopy } from 'vite-plugin-static-copy';
export default {
  vite: {
    plugins: [viteStaticCopy({
      targets: [{ src: 'node_modules/katex/dist/katex.min.css', dest: 'vendor/katex' },
                { src: 'node_modules/katex/dist/fonts/*', dest: 'vendor/katex/fonts' }],
    })],
  },
};

The historical argument for public CDNs — a shared browser cache across sites — no longer holds: browsers partition their HTTP cache by top-level site, so a library cached on another site is not reused on yours.

Step 3: Generate Hashes at Build Time for What Stays External

Where a file must stay external — a vendor requires it, or the file is very large and rarely used — never paste hashes by hand. Compute them from the exact file during the build and fail if the remote content changes:

// scripts/sri.mjs — fetch each pinned asset, hash it, rewrite the HTML
import { createHash } from 'node:crypto';
import { globSync, readFileSync, writeFileSync } from 'node:fs';

const ASSETS = ['https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css'];
const sri = {};
for (const url of ASSETS) {
  const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
  sri[url] = 'sha384-' + createHash('sha384').update(buf).digest('base64');
}
for (const f of globSync('dist/**/*.html')) {
  let html = readFileSync(f, 'utf8');
  for (const [url, hash] of Object.entries(sri)) {
    html = html.replaceAll(`href="${url}"`, `href="${url}" integrity="${hash}" crossorigin="anonymous"`)
               .replaceAll(`src="${url}"`, `src="${url}" integrity="${hash}" crossorigin="anonymous"`);
  }
  writeFileSync(f, html);
}

Commit the computed hashes to a lock file (sri.lock.json) and compare on each build. If a pinned, versioned URL ever returns different bytes, the build fails loudly — that is either a CDN problem or an attack, and in both cases you want to know before readers do.

Choosing a control for each external asset A decision flow. Can the file be bundled or copied into the build? If yes, self-host it. If not, is the URL versioned and immutable? If yes, use SRI with hashes generated at build time. If not, the file changes in place, so restrict it with CSP and isolate it in an iframe where possible. Self-host first, SRI second, CSP always can it be bundled? versioned, immutable URL? self-host SRI, hashed at build mutable: CSP allow-list + iframe isolation yes no yes no
On the site in this guide, five external assets became three self-hosted files, one SRI-checked stylesheet and one CSP-restricted analytics script.

Measured Impact

A documentation site loading five third-party assets, before and after the classification above. Performance from Lighthouse 12 mobile, median of five runs.

MeasureBeforeAfter
External script/style origins42
Assets without any integrity or origin control50
Self-hosted (lockfile-pinned)03
SRI-protected01
Connections before LCP52
LCP, docs template1.64 s1.41 s
Origins contacted before LCP, before and after Before, the docs page contacted its own origin plus four third-party origins before LCP: two library CDNs, a font CDN and an analytics host. After, it contacted its own origin plus one library CDN whose stylesheet is SRI-checked; the analytics host is contacted only after load. Connections opened before LCP Before own origin CDN 1 CDN 2 font CDN analytics After own origin CDN 1 + SRI analytics now after load Chrome DevTools, docs template, cold cache, simulated fast 4G
Every origin removed is one fewer party to trust and one fewer handshake before the page can paint.

Security and performance moved together: removing origins cut three connection setups from the critical path, the same effect measured in Preconnect vs DNS-Prefetch on Static Sites.

Keeping Versions Current

Pinning a version for SRI or self-hosting also freezes it, and frozen libraries accumulate vulnerabilities. Put pinned external URLs in a single config file, not scattered through templates, so a dependency bot or a scheduled job can check for newer releases and open a pull request that updates the URL and regenerates the hash together. For self-hosted libraries this is automatic, because they come from package.json and the normal dependency update flow covers them — one more reason self-hosting is the default. Review each update's changelog as you would any dependency; an integrity hash proves the file is the one you chose, not that you chose well.

What SRI Does Not Cover

SRI checks a file's bytes at load time; it says nothing about what the file does. A pinned library version with a known vulnerability passes SRI perfectly. It also cannot protect scripts loaded dynamically by other scripts — if your SRI-checked loader then fetches https://vendor.example/latest.js without an integrity value, that second file is unchecked. And it does not apply to iframes: an embedded player runs in its own document with its own resources. That is why SRI works alongside the other controls rather than replacing them: dependency auditing to catch vulnerable versions (Auditing npm Dependencies in SSG Pipelines), CSP to constrain dynamically loaded code, and sandboxed iframes for embeds.

Pitfalls & Rollback

  • SRI on a mutable URL. The vendor's next update breaks the page. Use it only on versioned, immutable URLs.
  • Missing crossorigin. Without CORS the browser cannot read the bytes, the check fails, and the asset is blocked.
  • Hand-pasted hashes. They go stale on version bumps. Generate them in the build and lock them.
  • Using a CDN for cache sharing. Browser caches are partitioned per site; the shared-cache benefit no longer exists.
  • Rollback: removing an integrity attribute restores unchecked loading immediately; reverting a self-hosting change points the tag back at the CDN. Both are single-commit changes.

Conclusion

Subresource Integrity turns "we trust this CDN" into "we trust these exact bytes". For a static site the better move is often to remove the CDN entirely by self-hosting at build time, which also makes pages faster; SRI is the right tool for immutable files that must stay external, with hashes generated and locked during the build. Mutable vendor scripts cannot use SRI at all and need CSP instead. On the docs site in this guide, that split left no third-party asset uncontrolled and cut LCP by 230 ms along the way.

FAQ

What does Subresource Integrity do?

It lets you state the expected cryptographic hash of a script or stylesheet in the integrity attribute. The browser downloads the file, hashes it, and refuses to use it if the hash does not match, so a file modified on the CDN never runs.

Can I use SRI with any third-party script?

Only with files whose content never changes at a given URL, such as versioned library files on a public CDN. Scripts that vendors update in place, like most analytics and tag loaders, change content without changing URL and would fail the check.

Is self-hosting better than SRI?

Usually, for a static site. Bundling the library at build time removes the third-party origin, the extra connection and the tampering risk in one step. SRI is for cases where the file must stay on an external host.

Why is crossorigin required with SRI?

The browser needs to read the response body to hash it, which for cross-origin requests requires CORS. Without crossorigin="anonymous", the integrity check fails and the resource is blocked.