Adding SRI to Rollup and esbuild Builds
Permalink to "Adding SRI to Rollup and esbuild Builds"Part of Static Asset Hash Generation, this page shows how to bolt Subresource Integrity onto two bundlers that ship no integrity feature at all — a Rollup plugin that hashes emitted chunks in generateBundle, and an esbuild post-build script driven by the metafile.
Quick Reference
Permalink to "Quick Reference"| Property | Rollup | esbuild |
|---|---|---|
| Built-in SRI | none | none |
| Extension point | generateBundle plugin hook |
post-build Node script |
| Output inventory | bundle object (in memory) |
result.metafile.outputs |
| Bytes to hash | chunk.code / asset.source |
file contents read from outdir |
| File-name hashing | entryFileNames: '[name]-[hash].js' |
entryNames: '[dir]/[name]-[hash]' |
| HTML generation | @rollup/plugin-html or your own |
not supported — render a template |
| Algorithm | sha384 (Node crypto) |
sha384 (Node crypto) |
| Required attributes | integrity and crossorigin="anonymous" |
same |
The rule that governs both: hash the bytes the bundler finally emitted, never the sources it started from.
The mental model: hash last, write once
Permalink to "The mental model: hash last, write once"An SRI digest is a promise about a specific byte sequence. A bundler breaks that promise constantly during a build — it tree-shakes, it renames, it minifies, it rewrites import specifiers so a chunk can find its siblings, and it stamps content hashes into file names. Every one of those steps changes bytes. So the only defensible place to compute a digest is the last moment at which the bundler still owns the file and nothing else will touch it.
Rollup makes that moment explicit. Its output phase runs renderChunk on each chunk, then resolves the [hash] placeholders in the configured file-name patterns against the finished code, then calls generateBundle with a bundle object whose keys are the final file names and whose values carry the final bytes. Only after generateBundle returns does Rollup write anything to disk. That ordering is the whole trick: inside generateBundle you can read final bytes, compute digests, and patch the HTML asset in place, and Rollup will write your edited HTML alongside the chunks as if it had produced it itself.
The ordering also explains a failure mode people hit constantly. Because the file-name hash is resolved before generateBundle, any plugin that rewrites a chunk’s code inside generateBundle produces a file whose name no longer describes its contents. Cache keys silently go stale, and if a second plugin hashes after your edit while a CDN caches by name, you ship a mismatch that only shows up in production. Rewriting the HTML asset is safe precisely because HTML is usually not content-hashed; rewriting chunk code at that point is not.
esbuild has no equivalent hook. Its plugin API exposes onResolve, onLoad, onStart and onEnd, and none of them hands you the finished output the way generateBundle does — onEnd receives the build result, which is where the metafile lives, but by then esbuild has already written to disk unless you asked it not to. That is fine: for esbuild the natural shape is a build script rather than a plugin, and the metafile gives it an exact inventory of what was produced.
Canonical example: a Rollup integrity plugin
Permalink to "Canonical example: a Rollup integrity plugin"This plugin hashes every emitted chunk and asset, patches the integrity and crossorigin attributes into any HTML asset in the bundle, and emits a manifest for the things HTML cannot reach. It has no dependencies beyond Node’s crypto.
// rollup-plugin-sri.mjs
import { createHash } from 'node:crypto';
const SRI_LINK_RELS = /\brel\s*=\s*["'](stylesheet|preload|modulepreload)["']/i;
const URL_ATTR = /\b(?:src|href)\s*=\s*["']([^"']+)["']/i;
function digest(source, algorithm) {
const bytes = typeof source === 'string' ? Buffer.from(source, 'utf8') : Buffer.from(source);
return `${algorithm}-${createHash(algorithm).update(bytes).digest('base64')}`;
}
export default function sri(options = {}) {
const {
algorithm = 'sha384',
crossorigin = 'anonymous',
htmlPattern = /\.html?$/,
manifest = 'sri-manifest.json'
} = options;
return {
name: 'sri',
// Runs after renderChunk and after [hash] placeholders are resolved,
// and before Rollup writes anything to disk.
generateBundle(_outputOptions, bundle) {
const digests = new Map();
for (const [fileName, item] of Object.entries(bundle)) {
if (htmlPattern.test(fileName)) continue;
const source = item.type === 'chunk' ? item.code : item.source;
digests.set(fileName, digest(source, algorithm));
}
for (const [fileName, item] of Object.entries(bundle)) {
if (item.type !== 'asset' || !htmlPattern.test(fileName)) continue;
item.source = String(item.source).replace(
/<(script|link)\b[^>]*>/gi,
(tag, name) => {
if (/\bintegrity\s*=/i.test(tag)) return tag;
if (name.toLowerCase() === 'link' && !SRI_LINK_RELS.test(tag)) return tag;
const url = tag.match(URL_ATTR)?.[1];
if (!url) return tag;
const key = url.replace(/^\.?\//, '').split(/[?#]/)[0];
const value = digests.get(key);
if (!value) return tag;
const extra = ` integrity="${value}" crossorigin="${crossorigin}"`;
return tag.replace(/\s*\/?>$/, (end) => extra + end);
}
);
}
if (manifest) {
this.emitFile({
type: 'asset',
fileName: manifest,
source: JSON.stringify(Object.fromEntries(digests), null, 2)
});
}
}
};
}
Two details make it correct rather than merely plausible. First, HTML assets are excluded from the digest map — an HTML document is navigated to, not subresource-loaded, and hashing a file you are about to rewrite would produce a value for bytes that never ship. Second, the <link> branch only stamps rel values that the SRI specification actually honours. Adding integrity to <link rel="icon"> is inert, but it is also a lie in your build output, and inert attributes are exactly the kind of thing that survives into an audit as false assurance.
Register it after whatever plugin produces the HTML, because Rollup runs generateBundle hooks in plugin order:
// rollup.config.mjs
import html from '@rollup/plugin-html';
import terser from '@rollup/plugin-terser';
import sri from './rollup-plugin-sri.mjs';
export default {
input: 'src/main.js',
output: {
dir: 'dist',
format: 'es',
entryFileNames: '[name]-[hash].js',
chunkFileNames: 'chunks/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]'
},
plugins: [
terser(),
html({ fileName: 'index.html' }),
sri({ algorithm: 'sha384' })
]
};
@rollup/plugin-terser minifies in renderChunk, so its output is what gets name-hashed and what the SRI plugin later digests. The resulting dist/index.html carries both attributes on every tag it can resolve:
<link rel="stylesheet" href="assets/main-C1r8Kd2p.css" integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC" crossorigin="anonymous">
<script type="module" src="main-BqT3xK9f.js" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY5iSjmoiUnp2G3sPqPqNPnRQnV" crossorigin="anonymous"></script>
crossorigin="anonymous" is not optional even for same-origin files. The specification requires a CORS-enabled fetch before a browser will validate a digest, and a same-origin request satisfies that trivially — but omit the attribute on a cross-origin URL and the browser blocks the resource outright. The interaction is unpacked in How CORS and crossorigin Affect SRI.
Canonical example: esbuild metafile plus a post-build rewrite
Permalink to "Canonical example: esbuild metafile plus a post-build rewrite"esbuild will not render HTML for you, so the script has two jobs: hash what was produced, and render a template with the resolved names and digests substituted in. Set metafile: true and esbuild returns an object whose outputs map is keyed by output path relative to the working directory, with an entryPoint field on any output that came from an entry point.
The template holds placeholder tokens where the file name and digest belong. Keeping the attributes in the template rather than synthesising the tags in JavaScript means the markup stays reviewable, and a missing substitution shows up as a literal {{...}} in the output instead of a silently absent attribute.
<!-- src/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/{{css}}" integrity="{{css.integrity}}" crossorigin="anonymous">
</head>
<body>
<div id="root"></div>
<script type="module" src="/{{js}}" integrity="{{js.integrity}}" crossorigin="anonymous"></script>
</body>
</html>
// build.mjs — run: node build.mjs
import { build } from 'esbuild';
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
const OUT_DIR = 'dist';
const ENTRY = 'src/main.js';
const result = await build({
entryPoints: [ENTRY],
bundle: true,
splitting: true,
format: 'esm',
minify: true,
outdir: OUT_DIR,
entryNames: '[dir]/[name]-[hash]',
chunkNames: 'chunks/[name]-[hash]',
assetNames: 'assets/[name]-[hash]',
metafile: true
});
const rel = (p) => path.relative(OUT_DIR, p).split(path.sep).join('/');
// 1. Hash every file esbuild reported, reading the exact bytes it wrote.
const digests = new Map();
for (const outputPath of Object.keys(result.metafile.outputs)) {
const bytes = await readFile(outputPath);
digests.set(rel(outputPath), 'sha384-' + createHash('sha384').update(bytes).digest('base64'));
}
// 2. Find the entry JS and its stylesheet by consulting the metafile, not by guessing.
let jsFile = null;
let cssFile = null;
for (const [outputPath, meta] of Object.entries(result.metafile.outputs)) {
const name = rel(outputPath);
if (meta.entryPoint === ENTRY && name.endsWith('.js')) jsFile = name;
if (name.endsWith('.css')) cssFile = name;
}
if (!jsFile) throw new Error(`no JS output found for entry point ${ENTRY}`);
// 3. Substitute names and digests into the template.
const tokens = {
'{{js}}': jsFile,
'{{js.integrity}}': digests.get(jsFile),
'{{css}}': cssFile,
'{{css.integrity}}': cssFile ? digests.get(cssFile) : ''
};
let html = await readFile('src/index.html', 'utf8');
for (const [token, value] of Object.entries(tokens)) {
html = html.split(token).join(value ?? '');
}
if (html.includes('{{')) throw new Error('unsubstituted token left in index.html');
await writeFile(path.join(OUT_DIR, 'index.html'), html);
await writeFile(
path.join(OUT_DIR, 'sri-manifest.json'),
JSON.stringify(Object.fromEntries(digests), null, 2)
);
console.log(`hashed ${digests.size} outputs`);
Reading each file back from disk rather than trusting an in-memory copy is deliberate. It costs a few milliseconds and it verifies the one thing that actually matters: that the digest describes the bytes a web server will later open. If you would rather never write an unhashed artefact at all, pass write: false and hash result.outputFiles instead — that variant is below.
Code-split chunks and runtime URLs
Permalink to "Code-split chunks and runtime URLs"Both bundlers turn a import('./panel.js') call into a reference to a separate chunk, and both leave the resulting URL inside the chunk’s own code. Nothing in the HTML document mentions chunks/panel-9fA2Kd.js, so there is no tag on which an integrity attribute could live. The plugin above will happily compute a digest for that chunk and put it in the manifest, but it has nowhere to stamp it.
Three partial answers exist, and it is worth being blunt that none of them is as complete as an attribute on a static tag. The first is the integrity key in an import map, which lets a document declare digests for module URLs the loader will later resolve; browser support is still uneven, so treat it as hardening that some visitors get rather than a guarantee, and see Using the Import Map integrity Key for the current picture. The second is <link rel="modulepreload"> in the HTML for chunks you can predict — the preload link itself accepts integrity, so the fetch it performs is validated, which covers the common case of a route chunk you know will be needed. The third is a loader you control that consults sri-manifest.json and fetches with a checked digest before evaluating; that route, and its trade-offs, is the subject of SRI for Lazy-Loaded Chunks.
Whichever you pick, the manifest is the shared prerequisite, which is why both implementations above emit one unconditionally. A digest that exists in a file is cheap; one you have to recompute at deploy time against artefacts you no longer control is not.
Variants
Permalink to "Variants"Hash in memory with write: false
Permalink to "Hash in memory with write: false" Setting write: false keeps every output in result.outputFiles, each with a path and a contents byte array. Hash those, then write the files yourself:
const result = await build({ /* ...as above... */ metafile: true, write: false });
const digests = new Map();
for (const file of result.outputFiles) {
const name = path.relative(OUT_DIR, file.path).split(path.sep).join('/');
digests.set(name, 'sha384-' + createHash('sha384').update(file.contents).digest('base64'));
await writeFile(file.path, file.contents);
}
This is the right shape when the build runs inside a larger pipeline that uploads directly to object storage — nothing unhashed ever lands on a disk another process could touch.
writeBundle instead of generateBundle
Permalink to "writeBundle instead of generateBundle" If you need integrity values for files that other tools produce after Rollup, move the work to writeBundle and read from outputOptions.dir. You lose the ability to patch the HTML asset in memory and must rewrite it with fs, but you gain visibility of the complete output directory:
writeBundle(outputOptions, bundle) {
const dir = outputOptions.dir;
// every file listed in `bundle` is now on disk under `dir`
// read, hash, then rewrite dir/index.html with fs.writeFileSync
}
Both algorithms in one attribute
Permalink to "Both algorithms in one attribute"The integrity attribute accepts a space-separated list. A browser picks the strongest algorithm it recognises, so listing two costs nothing but bytes and lets you rotate:
<script type="module" src="/main-BqT3xK9f.js" integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC sha512-Z0uPjwqCzC0mMbeE1M0PIRr9jHgpFBiWXjt8vXLqOWmXvR8yAcyNvJmp7iOqXk5f" crossorigin="anonymous">
Change the plugin’s algorithm option into an array and join the resulting tokens with a space. The same technique applies in other bundlers — see Automating Hash Generation in Webpack 5 for the plugin-based equivalent, and Generating SRI Hashes in Vite if your Rollup config is actually a Vite build in disguise.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
Dev servers will never match. Rollup watch mode and esbuild’s
context()plusserve()emit unminified, unhashed output, and a serve setup commonly injects a live-reload client into the response. Any digest computed from a production build fails against those bytes. Register the SRI step only in the production configuration; if you must share one config, gate it on an explicit flag rather than onNODE_ENV, which build tools set inconsistently. -
Anything that touches a file after hashing breaks it. A standalone minifier, a licence-banner step, a
sedin a deploy script, a CDN that re-compresses or injects a script — each rewrites bytes the digest already described. The symptom is a console error naming the file and both digests, which is decoded in Debugging SRI Hash Mismatch Errors. -
Omitting
crossoriginsilently disarms the check on cross-origin URLs. Without it the browser makes a no-CORS request, gets an opaque response it cannot read, and blocks the resource rather than validating it. On same-origin files the omission is less visible but still wrong, because moving those files to a CDN later turns a working page into a blocked one with no code change. -
A content-hashed file name is not integrity.
main-BqT3xK9f.jsproves the URL changes when the content changes, which is a caching property. It says nothing about whether the bytes served under that name are the bytes you built. Both mechanisms are worth having and neither substitutes for the other. -
Base64 padding and encoding must be exact. The digest is standard base64 of the raw hash bytes, not hex and not base64url; a stray newline from a shell pipeline is the classic cause of a value that looks right and validates nowhere. The rules are set out in Base64 Encoding Rules for SRI Hashes.
Verification Steps
Permalink to "Verification Steps"1. Confirm every eligible tag carries both attributes
Permalink to "1. Confirm every eligible tag carries both attributes"grep -oE '<(script|link)[^>]*>' dist/index.html | grep -v 'integrity='
Expected output is empty. Any line printed is a tag the plugin failed to match — usually because the src value has a public path prefix your key normalisation does not strip.
2. Re-hash a shipped file and compare
Permalink to "2. Re-hash a shipped file and compare"openssl dgst -sha384 -binary dist/main-BqT3xK9f.js | openssl base64 -A
The printed string must equal the text after sha384- in the attribute, character for character. The equivalent shasum invocation and the pitfalls of each are covered in Generating SRI Hashes with OpenSSL and shasum.
3. Check the manifest against the directory
Permalink to "3. Check the manifest against the directory"node -e "
const m=require('./dist/sri-manifest.json'),c=require('node:crypto'),f=require('node:fs');
let bad=0;
for(const [k,v] of Object.entries(m)){
const d='sha384-'+c.createHash('sha384').update(f.readFileSync('dist/'+k)).digest('base64');
if(d!==v){console.error('MISMATCH '+k);bad++;}
}
console.log(bad?'FAIL':'all '+Object.keys(m).length+' outputs match');
process.exit(bad?1:0);"
Expected output on a clean build: all 7 outputs match, or whatever your output count is.
4. Load the page and watch for silence
Permalink to "4. Load the page and watch for silence"Serve dist/ over HTTP and open DevTools. A passing build produces no console output at all; a failure produces a message naming the resource and stating that it does not match its integrity digest, and the resource does not execute. Wire the same check into the pipeline so a stale attribute cannot reach production — see Failing CI on SRI Hash Drift.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Do Rollup or esbuild generate SRI hashes natively?
Neither does. Rollup has no integrity option and esbuild has no integrity flag; both stop at emitting bytes. Rollup gives you the generateBundle plugin hook to compute digests before anything is written, and esbuild gives you the metafile, which lists every output path so a post-build script can hash them. The wiring is yours in both cases.
Which Rollup hook should compute the digest?
generateBundle. By the time it runs, renderChunk has finished, minification is done, and the file-name hash placeholders have been resolved, so the bundle object holds final bytes under final names. renderChunk is too early because later plugins can still rewrite code, and writeBundle is workable but forces you to re-read every file from disk.
Why does an integrity value break when I minify after the bundler?
Because the digest describes bytes that no longer exist. Any step that touches a file after hashing, including a standalone minifier, a licence-banner injector, a gzip-and-rewrite deploy script, or an edge worker that rewrites responses, invalidates the value. Hashing must be the last operation performed on the file before it is served.
Can a dynamically imported chunk carry an integrity attribute?
No, because there is no tag to put the attribute on. A dynamic import resolves its URL inside the chunk at runtime and the module loader fetches it directly. The available options are an import map integrity entry, a modulepreload link in the HTML for chunks you can predict, or a custom loader that reads digests from a manifest.
Should the plugin run during development?
No. A dev server serves transformed, unminified sources and often injects a live-reload client, so any digest computed against a production build will not match what the browser receives. Register the plugin only in the production configuration, or gate it on an environment check, and test integrity against the real build output.
Related
Permalink to "Related"- Generating an SRI Manifest in GitHub Actions — turning the manifest these builds emit into a versioned pipeline artefact
- Verifying Deployed Assets Against a Hash Manifest — checking that what a CDN actually serves still matches what you built
- SRI for ES Module Imports — how module scripts, static imports and integrity interact in the browser