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.
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:
| Asset | URL type | Action |
|---|---|---|
| KaTeX CSS from jsDelivr, pinned version | immutable | add SRI (or self-host) |
| Chart.js from a CDN, pinned version | immutable | self-host at build time |
Analytics loader (/js/script.js) | mutable, vendor-updated | cannot use SRI; restrict with CSP |
| Video embed player | mutable, in an iframe | isolate in iframe; CSP frame-src |
| Google Fonts CSS | mutable, varies per browser | self-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.
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.
| Measure | Before | After |
|---|---|---|
| External script/style origins | 4 | 2 |
| Assets without any integrity or origin control | 5 | 0 |
| Self-hosted (lockfile-pinned) | 0 | 3 |
| SRI-protected | 0 | 1 |
| Connections before LCP | 5 | 2 |
| LCP, docs template | 1.64 s | 1.41 s |
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
integrityattribute 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.
Related
- Parent: Security Headers and Hardening for Static Sites — the full hardening set.
- Hash-Based CSP for Inline Scripts in Astro — the same hashing idea for inline code.
- Self-Hosting Analytics to Cut Third-Party Requests — removing a mutable vendor script.
- Self-Hosting Google Fonts to Eliminate Layout Shift — the font equivalent.
- Auditing npm Dependencies in SSG Pipelines — what SRI cannot catch.