Generating SRI Hashes in Vite
Permalink to "Generating SRI Hashes in Vite"Part of Static Asset Hash Generation, this page shows exactly how to make a Vite build emit <script> and <link> tags that already carry a valid sha384-… integrity attribute — using a custom transformIndexHtml plugin, an off-the-shelf plugin, or a Rollup output hook.
Quick Reference
Permalink to "Quick Reference"| Property | Value |
|---|---|
| Build hook | transformIndexHtml (enforce: 'post', apply: 'build') |
| Bundle access | ctx.bundle — chunk .code, asset .source |
| Default algorithm | sha384 |
| Required attributes | integrity="sha384-…" + crossorigin="anonymous" |
| Dev server | No SRI (unbundled ESM) — build only |
| Off-the-shelf plugin | vite-plugin-sri3 |
| Rollup fallback | rollup-plugin-sri via build.rollupOptions.plugins |
Hash the emitted bytes, never the source. Vite minifies, tree-shakes, and renames files, so the only reliable input is the final content in dist/.
Why Vite Needs a Post-Build Hook
Permalink to "Why Vite Needs a Post-Build Hook"Vite runs an unbundled native-ESM dev server and a Rollup-powered production build. During vite build, Rollup produces the final minified, content-hashed chunks and Vite injects <script type="module"> and <link rel="stylesheet"> references into index.html. Subresource Integrity requires the digest to match the exact bytes the browser downloads — which only exist after minification and hashing complete. A plugin therefore has to run after the bundle is generated, read the emitted output, compute a digest over those precise bytes, and rewrite the tags. The mechanics of that digest step are covered in How to Calculate SHA-256 vs SHA-384 for SRI; here the goal is wiring it into Vite’s plugin pipeline so no hash is ever managed by hand.
The transformIndexHtml hook, when marked enforce: 'post', receives a context object whose bundle property is the complete Rollup output — every chunk and asset keyed by filename. That is the correct, byte-accurate source for hashing. A chunk entry exposes its final code on .code; an asset entry exposes its bytes on .source. Reading from ctx.bundle rather than the filesystem avoids a subtle race where a later plugin rewrites a file after you have already hashed it on disk.
Vite also injects references beyond the entry script: <link rel="modulepreload"> for eagerly loaded chunks and, when @vitejs/plugin-legacy is active, a nomodule fallback script plus a SystemJS loader. Each of those is a fetchable resource, so a complete integrity story stamps them too — the canonical plugin below matches any <script> or <link> whose target exists in the bundle, which covers preload links and legacy fallbacks alike.
Canonical Implementation
Permalink to "Canonical Implementation"The plugin below computes a SHA-384 digest for every emitted script and stylesheet and injects both integrity and crossorigin="anonymous". Drop it into your project and register it in vite.config.js.
// plugins/vite-plugin-sri.js
import { createHash } from 'node:crypto';
export default function sri({ algorithm = 'sha384' } = {}) {
return {
name: 'vite-plugin-sri',
apply: 'build', // never runs on the dev server
enforce: 'post', // runs after Vite emits the final bundle
transformIndexHtml(html, ctx) {
if (!ctx.bundle) return html;
return html.replace(/<(script|link)\b[^>]*>/g, (tag) => {
if (/\bintegrity=/.test(tag)) return tag; // already stamped
const url = tag.match(/\b(?:src|href)="([^"]+)"/)?.[1];
if (!url) return tag;
const fileName = url.replace(/^\/|^\.\//, ''); // dist-relative key
const chunk = ctx.bundle[fileName];
if (!chunk) return tag; // external / not emitted
const source = chunk.type === 'chunk' ? chunk.code : chunk.source;
const digest = createHash(algorithm).update(source).digest('base64');
return tag.replace(
/>$/,
` integrity="${algorithm}-${digest}" crossorigin="anonymous">`,
);
});
},
};
}
// vite.config.js
import { defineConfig } from 'vite';
import sri from './plugins/vite-plugin-sri.js';
export default defineConfig({
plugins: [sri({ algorithm: 'sha384' })],
});
After vite build, dist/index.html contains:
<script type="module" crossorigin="anonymous"
src="/assets/index.4f3a1b2c.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"></script>
Variant Examples
Permalink to "Variant Examples"Off-the-shelf plugin: vite-plugin-sri3
Permalink to "Off-the-shelf plugin: vite-plugin-sri3"When you would rather not maintain a plugin, vite-plugin-sri3 (a maintained fork that tracks current Vite majors) does the same transformIndexHtml work and defaults to SHA-384:
// vite.config.js
import { defineConfig } from 'vite';
import sri from 'vite-plugin-sri3';
export default defineConfig({
plugins: [sri()], // sha384 by default; injects integrity + crossorigin
});
Rollup output hook via build.rollupOptions
Permalink to "Rollup output hook via build.rollupOptions"Because Vite delegates the production build to Rollup, a Rollup SRI plugin composes cleanly under build.rollupOptions.plugins:
// vite.config.js
import { defineConfig } from 'vite';
import sri from 'rollup-plugin-sri';
export default defineConfig({
build: {
rollupOptions: {
plugins: [
sri({ algorithms: ['sha384'], crossorigin: 'anonymous' }),
],
},
},
});
Manual generateBundle manifest hook
Permalink to "Manual generateBundle manifest hook"If you need the digests for a separate CDN manifest rather than inline attributes, compute them in a small inline Rollup plugin and emit a JSON file:
// vite.config.js — emit an sri-manifest.json alongside dist
import { defineConfig } from 'vite';
import { createHash } from 'node:crypto';
function sriManifest(algorithm = 'sha384') {
return {
name: 'sri-manifest',
apply: 'build',
generateBundle(_options, bundle) {
const manifest = {};
for (const [fileName, chunk] of Object.entries(bundle)) {
const source = chunk.type === 'chunk' ? chunk.code : chunk.source;
const digest = createHash(algorithm).update(source).digest('base64');
manifest['/' + fileName] = `${algorithm}-${digest}`;
}
this.emitFile({
type: 'asset',
fileName: 'sri-manifest.json',
source: JSON.stringify(manifest, null, 2),
});
},
};
}
export default defineConfig({ plugins: [sriManifest()] });
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"- Omitting
crossorigin="anonymous"blocks cross-origin chunks. If Vite is configured with a CDNbaseso chunks load from another origin, anintegrityattribute withoutcrossorigintriggers a no-CORS fetch that returns an opaque response. The browser cannot read opaque bytes to compare the hash, so it blocks the script outright. This is the single most common SRI bug — always emit both attributes together, and ensure the CDN returnsAccess-Control-Allow-Origin. - Hash the emitted bytes, not the source. Running the digest before
enforce: 'post'(or in atransformhook) captures pre-minified code. Vite’s esbuild/terser pass and content-hash renaming both change the bytes, so an early hash is guaranteed not to match what ships indist/. - The dev server has no SRI.
vite(dev) serves unbundled native ESM and never produces the final artifacts, so integrity attributes will not appear. Gate the plugin withapply: 'build'and validate againstvite previewor the deployeddist/. - Hashed filenames are not a substitute. Content-hashed names like
index.4f3a1b.jsonly cache-bust the URL; they give the browser no way to reject altered bytes. SRI and filename hashing are complementary, not interchangeable. - Non-emitted references are skipped. Third-party tags you hand-write into
index.html(analytics, fonts on a public CDN) are not inctx.bundle, so the canonical plugin leaves them untouched. Hash those separately from the exact URL they load. modulepreloadlinks need integrity too. A<link rel="modulepreload">primes the module cache; if the preloaded bytes carry no integrity, the browser still validates the eventual<script>, but adding integrity to both closes the window and keeps the preload from populating the cache with unverified content. Matchlinkas well asscriptin the plugin.- Legacy builds emit an extra graph.
@vitejs/plugin-legacyproduces a second set of transpiled chunks and anomodulescript. Because those files land inctx.bundleunder their own names, run the SRI plugin after the legacy plugin so both the modern and legacy graphs are stamped in one pass.
Verification Steps
Permalink to "Verification Steps"1. Confirm every emitted tag carries both attributes
Permalink to "1. Confirm every emitted tag carries both attributes"grep -Eo '<(script|link)[^>]*>' dist/index.html | grep -E 'integrity=|crossorigin='
Expected — each script and stylesheet reference shows a sha384- token next to crossorigin="anonymous".
2. Re-hash the emitted file and compare
Permalink to "2. Re-hash the emitted file and compare"openssl dgst -sha384 -binary dist/assets/index.4f3a1b2c.js | openssl base64 -A
The output must match the token in dist/index.html character-for-character. Any difference means the hash was computed over the wrong bytes.
3. Confirm clean validation in the browser
Permalink to "3. Confirm clean validation in the browser"Run vite preview, open the page with DevTools, and check the console. A mismatch produces:
Failed to find a valid digest in the 'integrity' attribute for resource
'/assets/index.4f3a1b2c.js' with computed SHA-384 integrity 'sha384-…'.
The resource has been blocked.
No message plus a 200 OK in the Network tab means the tokens are correct.
4. Gate CI on drift
Permalink to "4. Gate CI on drift"Add a post-build step that re-derives each digest from dist/ and diffs it against the emitted HTML, exiting non-zero on any mismatch so a broken build never reaches a CDN edge node.
Related
Permalink to "Related"- Static Asset Hash Generation — parent workflow covering deterministic build output and the manifest this plugin can feed
- Automating Hash Generation in Webpack 5 — the equivalent
webpack-subresource-integritysetup for Webpack pipelines - How to Calculate SHA-256 vs SHA-384 for SRI — the underlying digest step and why SHA-384 is the default