Generating SRI Hashes in Your Build Pipeline

Computing integrity attribute values by hand does not scale past a handful of static assets — build pipelines that emit dozens of hashed chunks need automated SRI generation wired into the same step that writes fingerprinted filenames.

Symptom and Decision Framing

If your deployment process includes any of the following, you need pipeline-integrated SRI generation rather than manual hash computation:

  • More than five independently cached asset files per page
  • Code splitting (dynamic import()) that emits an unpredictable number of chunks per build
  • A CI/CD workflow that builds and deploys without human review of individual file names
  • Asset filenames that change on every meaningful code edit (content-addressed hashing)

Manual openssl commands remain useful for spot-checking individual files and for verifying that a deployed asset matches its build-time hash, but they cannot reliably track every chunk emitted by a modern bundler. The SRI validation reference explains how browsers use these hashes; this guide focuses entirely on generating them correctly during the build.

Concept Clarification: What Gets Hashed

SRI hashes are computed over the final bytes of the output file as it will be served to the browser, after all bundler transforms (minification, tree-shaking, scope-hoisting) but before transport encoding (gzip/Brotli). This means:

  • Compute the hash after the bundler writes the file to disk.
  • Do not compute the hash on source files, on intermediate representations, or on the compressed version of the file.
  • If your CDN applies further content transforms (auto-minification, whitespace removal), those transforms change the bytes and therefore invalidate the hash. Disable content transforms on CDN for SRI-protected paths (see debugging SRI validation failures for the full diagnosis workflow).

The fingerprint in the filename (e.g., main.a1b2c3d4.js — 8 hex chars by default, or 12–16 for large monorepos with many chunks) is derived from the same content hash used for cache key architecture, but it is truncated and hex-encoded. The SRI hash is the full SHA-384 (or SHA-512) digest base64-encoded — a completely different representation of the same byte stream.

There is exactly one correct place in the pipeline to take that digest, and every generation strategy on this page is really a different way of arriving at the same point. Everything the bundler does before the file lands on disk is inside the digest: module resolution, tree-shaking, scope hoisting, the terser pass, banner comments, the trailing source-map annotation. Everything after it is outside: gzip, Brotli, HTTP/2 header compression, TLS. The practical consequence is that a plugin computing hashes from the in-memory bundle object and a shell script hashing the emitted file produce identical values — provided the plugin runs after the final transform and nothing rewrites the file afterwards.

Where the SRI digest boundary sits Source, bundling, minification and writing to disk all change the bytes covered by the digest. Transport compression happens after the boundary and is transparent to the integrity check. digest boundary Source TypeScript, CSS Bundle tree-shake Minify terser output Write file main.a1b2c3d4.js Transport gzip / brotli Every stage left of the line changes the digest. Right of it is transparent. The browser hashes the decompressed body, so Content-Encoding never alters the value.
Hash the file as written to disk: after every bundler transform, before any transport encoding.

Comparison: Generation Approaches

Approach Toolchain fit Dynamic chunks CI friction Hash algorithm control
OpenSSL one-liner (manual) Any None — manual per file High Full
webpack-subresource-integrity Webpack 5 Full (patches runtime) None hashFuncNames array
vite-plugin-sri3 Vite 5 / Rollup 4 Entry + static chunks None algorithms array
Custom Rollup generateBundle hook Rollup 4 Entry + static chunks Low (few lines) Full
Node.js manifest script (post-build) Any (reads manifest.json) Limited by manifest entries Low Full
Build-time SRI generation Source files enter the bundler, which emits hashed output files. A hash computation step reads those files and writes integrity values into a manifest, which the HTML template reads to inject integrity attributes. Source src/*.ts, *.css Bundler minify + split Hashed Output main.a1b2c3d4.js styles.b3c4d5e6.css SRI Compute SHA-384 → base64 per output file Manifest file → integrity mapping HTML Output integrity added crossorigin=anon Plugin-automated path (webpack-subresource-integrity / vite-plugin-sri3) or a Node.js script reading manifest.json after the build
Build-time SRI generation: the bundler emits hashed output files, a hash computation step produces base64 digests, and those values are written into the HTML output via a manifest.

OpenSSL One-Liners

For individual files, openssl is the fastest path to a correct hash:

# SHA-256 (acceptable but prefer SHA-384 or SHA-512 for SRI)
openssl dgst -sha256 -binary dist/assets/main.a1b2c3d4.js | openssl base64 -A

# SHA-384 (recommended for new deployments)
openssl dgst -sha384 -binary dist/assets/main.a1b2c3d4.js | openssl base64 -A

# SHA-512
openssl dgst -sha512 -binary dist/assets/main.a1b2c3d4.js | openssl base64 -A

# Emit the full integrity attribute value ready to paste into HTML
printf "sha384-"; openssl dgst -sha384 -binary dist/assets/main.a1b2c3d4.js | openssl base64 -A

# Hash every JS file in the dist directory
find dist/assets -name "*.js" -exec sh -c \
  'printf "sha384-"; openssl dgst -sha384 -binary "$1" | openssl base64 -A; echo " $1"' \
  _ {} \;

These commands operate on the local file after the bundler has written it — which is the correct input for SRI. If you need to verify a deployed file, download it first with curl -s --compressed (to decompress transport encoding), then pipe to openssl.

Webpack: webpack-subresource-integrity

This plugin integrates with Webpack’s asset emit pipeline and patches the runtime chunk loader to include integrity values for dynamically imported chunks:

npm install --save-dev webpack-subresource-integrity html-webpack-plugin
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity');

module.exports = {
  mode: 'production',
  entry: './src/index.ts',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
    // Required: enables CORS on dynamically loaded chunks
    crossOriginLoading: 'anonymous',
    clean: true,
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'src/index.html',
      inject: true,
    }),
    new SubresourceIntegrityPlugin({
      // sha384 is recommended; you can include multiple algorithms
      hashFuncNames: ['sha384'],
      // 'always' regardless of mode; 'auto' only in production
      enabled: 'always',
    }),
  ],
  optimization: {
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    runtimeChunk: 'single',
    splitChunks: {
      chunks: 'all',
    },
  },
};

The plugin writes integrity values into the Webpack stats object. HtmlWebpackPlugin reads those stats and emits correct <script integrity="..." crossorigin="anonymous"> tags automatically. For dynamic chunks, the plugin patches __webpack_require__.l so every runtime fetch() includes the precomputed integrity string.

For Webpack output hashing configuration details independent of SRI, see the dedicated Webpack guide.

Vite: vite-plugin-sri3

Vite 5 uses Rollup 4 under the hood. The vite-plugin-sri3 package hooks into the writeBundle phase to compute hashes and rewrite the HTML output:

npm install --save-dev vite-plugin-sri3
// vite.config.js
import { defineConfig } from 'vite';
import sri from 'vite-plugin-sri3';

export default defineConfig({
  build: {
    // Ensure deterministic filenames for reliable SRI
    rollupOptions: {
      output: {
        entryFileNames: 'entry-[name]-[hash:8].js',
        chunkFileNames: 'chunks/[name]-[hash:8].js',
        assetFileNames: 'assets/[name]-[hash:8][extname]',
      },
    },
    // Write manifest so post-build scripts can also read integrity values
    manifest: true,
  },
  plugins: [
    sri({
      algorithms: ['sha384'],
      // Set to true to also cover link[rel=preload] tags
      ignoreMissingResource: false,
    }),
  ],
});

After build, dist/index.html will include:

<script
  type="module"
  src="/entry-main-a1b2c3d4.js"
  integrity="sha384-..."
  crossorigin="anonymous"
></script>
<link
  rel="stylesheet"
  href="/assets/index-b3c4d5e6.css"
  integrity="sha384-..."
  crossorigin="anonymous"
/>

Note that vite-plugin-sri3 does not patch Vite’s runtime dynamic import mechanism. If your application uses import() for route-based code splitting, the dynamically loaded chunks will not carry integrity attributes unless you configure an import map or use a separate runtime approach.

Custom Rollup generateBundle Hook

For Rollup asset optimization workflows without a higher-level framework, add a small plugin directly:

// rollup.config.js
import { createHash } from 'node:crypto';

function sriPlugin(algorithms = ['sha384']) {
  const integrityMap = new Map();

  return {
    name: 'sri',
    generateBundle(_options, bundle) {
      for (const [fileName, chunk] of Object.entries(bundle)) {
        if (chunk.type === 'chunk' || chunk.type === 'asset') {
          const content =
            chunk.type === 'chunk'
              ? Buffer.from(chunk.code, 'utf8')
              : Buffer.isBuffer(chunk.source)
                ? chunk.source
                : Buffer.from(chunk.source, 'utf8');

          const hashes = algorithms.map((algo) => {
            const digest = createHash(algo).update(content).digest('base64');
            return `${algo}-${digest}`;
          });
          integrityMap.set(fileName, hashes.join(' '));
        }
      }
    },
    writeBundle() {
      // Emit the integrity map as a JSON file for consumption by the HTML template
      this.emitFile({
        type: 'asset',
        fileName: 'integrity-manifest.json',
        source: JSON.stringify(Object.fromEntries(integrityMap), null, 2),
      });
    },
  };
}

export default {
  input: 'src/main.js',
  output: {
    dir: 'dist',
    format: 'es',
    entryFileNames: '[name]-[hash:8].js',
    chunkFileNames: '[name]-[hash:8].js',
    assetFileNames: 'assets/[name]-[hash:8][extname]',
  },
  plugins: [sriPlugin(['sha384'])],
};

The integrity-manifest.json output maps each filename to its integrity string. Your HTML template (Nunjucks, EJS, Handlebars, or server-rendered) reads this file and renders integrity attributes accordingly.

Node.js Manifest Script (Post-Build)

When your build tool does not support plugin hooks, a post-build Node.js script reading the Vite or Webpack manifest achieves the same result:

// scripts/sri-manifest.mjs
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const DIST = join(__dirname, '..', 'dist');

// Vite manifest lives at dist/.vite/manifest.json
// Format: { "src/main.ts": { "file": "assets/main-a1b2c3d4.js", "css": ["assets/index-b3c4d5e6.css"] } }
const VITE_MANIFEST = join(DIST, '.vite', 'manifest.json');
const INTEGRITY_OUTPUT = join(DIST, 'sri-manifest.json');

async function sha384(filePath) {
  const bytes = await readFile(filePath);
  return 'sha384-' + createHash('sha384').update(bytes).digest('base64');
}

async function main() {
  const manifest = JSON.parse(await readFile(VITE_MANIFEST, 'utf8'));
  const result = {};

  for (const [_key, entry] of Object.entries(manifest)) {
    if (entry.file) {
      const abs = join(DIST, entry.file);
      result[entry.file] = await sha384(abs);
    }
    for (const cssFile of entry.css ?? []) {
      const abs = join(DIST, cssFile);
      result[cssFile] = await sha384(abs);
    }
  }

  await writeFile(INTEGRITY_OUTPUT, JSON.stringify(result, null, 2), 'utf8');
  console.log(`Wrote ${Object.keys(result).length} integrity entries to ${INTEGRITY_OUTPUT}`);
}

main().catch((err) => { console.error(err); process.exit(1); });

Add it to your package.json build pipeline:

{
  "scripts": {
    "build": "vite build",
    "build:sri": "vite build && node scripts/sri-manifest.mjs",
    "preview": "vite preview"
  }
}

For CI workflows, see CI/CD asset pipeline integration for how to wire this script into a GitHub Actions or GitLab CI job and fail the build if the SRI manifest is empty or stale.

esbuild: Hashing From the Metafile

esbuild 0.20+ has no SRI plugin, but its metafile already enumerates every emitted output, which is all a hash step needs. Build with metafile: true and walk the outputs:

// scripts/esbuild-sri.mjs
import { build } from 'esbuild';
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';

const result = await build({
  entryPoints: ['src/main.ts'],
  bundle: true,
  minify: true,
  splitting: true,
  format: 'esm',
  outdir: 'dist/assets',
  entryNames: '[name]-[hash]',
  chunkNames: 'chunks/[name]-[hash]',
  assetNames: '[name]-[hash]',
  metafile: true,
});

const integrity = {};
for (const outputPath of Object.keys(result.metafile.outputs)) {
  const bytes = await readFile(outputPath);
  integrity[outputPath] = 'sha384-' + createHash('sha384').update(bytes).digest('base64');
}

await writeFile('dist/sri-manifest.json', JSON.stringify(integrity, null, 2), 'utf8');
console.log(`Hashed ${Object.keys(integrity).length} esbuild outputs`);

esbuild’s [hash] placeholder is not length-configurable the way Webpack’s [contenthash:8] is, so the filenames will be longer than the eight hex characters used elsewhere on this page. That has no bearing on the integrity value — the digest is computed over content, never over the name.

Ordering the Steps in CI

Generation correctness is mostly an ordering problem. The digest describes a specific file; the HTML that carries it must be produced from the same run that produced that file. Any workflow where those two artifacts can be built independently will eventually ship a mismatch, and it will happen on the deploy where someone re-ran only one job.

CI step ordering for SRI generation In the correct order the build emits hashed files, a hash step digests them, an inject step writes the attributes, and deploy ships assets then HTML. In the broken order the HTML is injected from a previous run's manifest, so the shipped integrity values describe files that no longer exist. Correct — one command, one artifact build emit hashed files hash digest each output inject write HTML attributes deploy assets, then HTML Broken — HTML injected from an earlier run inject stale manifest build new hashes deploy HTML and assets Blocked stale integrity
Injection has to read a manifest produced by the same build that emitted the files it names.

Three properties make the ordering safe. First, hashing and injection belong in the same script invocation as the bundle step, so a partial re-run is impossible. Second, the injection step should fail loudly when the manifest is missing an entry it needs, rather than silently emitting a tag without an integrity attribute — a missing attribute is a silent loss of the control you thought you had. Third, the deploy uploads assets before HTML, so a browser that fetches the new HTML always finds the fingerprinted files it references already present at the edge.

Verification

After building, confirm integrity values were computed and match the files:

# Verify a single file's integrity value against the manifest
FILE="dist/assets/main.a1b2c3d4.js"
EXPECTED_INTEGRITY=$(cat dist/sri-manifest.json | python3 -c \
  "import json,sys; m=json.load(sys.stdin); print(m.get('assets/main.a1b2c3d4.js','NOT_FOUND'))")
ACTUAL_INTEGRITY="sha384-$(openssl dgst -sha384 -binary "$FILE" | openssl base64 -A)"
if [ "$EXPECTED_INTEGRITY" = "$ACTUAL_INTEGRITY" ]; then
  echo "PASS: integrity matches"
else
  echo "FAIL: expected $EXPECTED_INTEGRITY but computed $ACTUAL_INTEGRITY"
  exit 1
fi

When to Reconsider

Automated build-pipeline SRI generation becomes problematic when:

  • Assets are post-processed by the CDN — if Cloudflare Auto Minify or CloudFront edge functions modify asset bytes after your build computes hashes, the served bytes differ from the hashed bytes. Disable CDN content transforms for SRI-protected assets.
  • You use CDN-level bundling or on-the-fly concatenation — hashes must be computed on the exact bytes the browser receives. If the CDN concatenates two files into one, no build-time hash covers the concatenated output.
  • Hashes must cover assets from a third-party origin — you cannot compute hashes at build time for assets whose content you do not control. Instead, fetch and hash those assets as part of your CI pipeline and pin the hash in your template.

Frequently Asked Questions

Should the generation step emit one algorithm or several?

Emit one. A single sha384 digest keeps the attribute short and the HTML small, and browsers only ever evaluate the strongest algorithm they recognise anyway, so a second weaker value adds bytes without adding checks. The exception is a deliberate migration window: list both the outgoing and incoming algorithms for one release, confirm no blocks appear in telemetry, then drop the old one.

Can the generation step run against the deploy artifact instead of the build directory?

Yes, and for high-assurance pipelines it is the better choice. Hashing the tarball or container layer you are about to upload closes the gap between “what the bundler wrote” and “what the deploy job actually ships”, which catches a whole class of packaging bugs — a post-build sed, a file-mode normalisation, an accidental line-ending conversion on a Windows runner. The trade-off is that the injection step then has to run inside the same packaging stage.

Does the SRI hash have to use the same algorithm as the filename fingerprint?

No, and they usually should not match. Filename fingerprints optimise for short, collision-resistant-enough identifiers; the eight-character default is fine, and 12–16 characters suits monorepos with thousands of chunks. The integrity value optimises for cryptographic strength and is always a full digest. Nothing in either system reads the other’s output.

What breaks if the plugin runs before minification?

The digest covers pre-minified bytes while the browser receives minified ones, so every protected resource is blocked on every page load. This is a real failure mode with hand-written plugins that hook an early bundler phase; place the hook in generateBundle or later for Rollup, and rely on the emit stage for Webpack.