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.


Vite SRI Injection Pipeline Source modules are bundled and minified by Rollup into hashed chunks, then a post-enforced transformIndexHtml plugin reads the emitted bytes, computes SHA-384 digests, and writes integrity and crossorigin attributes into the dist HTML. Source .ts / .css Rollup build minify + hash post plugin transformIndexHtml SHA-384(ctx.bundle) dist HTML integrity="sha384-…" crossorigin="anonymous"

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 CDN base so chunks load from another origin, an integrity attribute without crossorigin triggers 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 returns Access-Control-Allow-Origin.
  • Hash the emitted bytes, not the source. Running the digest before enforce: 'post' (or in a transform hook) 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 in dist/.
  • 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 with apply: 'build' and validate against vite preview or the deployed dist/.
  • Hashed filenames are not a substitute. Content-hashed names like index.4f3a1b.js only 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 in ctx.bundle, so the canonical plugin leaves them untouched. Hash those separately from the exact URL they load.
  • modulepreload links 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. Match link as well as script in the plugin.
  • Legacy builds emit an extra graph. @vitejs/plugin-legacy produces a second set of transpiled chunks and a nomodule script. Because those files land in ctx.bundle under 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.


Permalink to "Related"

Related Articles

Automating Hash Generation in Webpack 5
Static Asset Hash Generation Asset Hashing & Dynamic Script…