Static Asset Hash Generation
Permalink to "Static Asset Hash Generation"This workflow is part of Asset Hashing & Dynamic Script Injection. Without deterministic hash generation at build time, every downstream control — browser integrity checks, CDN routing policies, and CI/CD gates — operates on an unverifiable foundation. A single non-deterministic build step produces divergent checksums between staging and production, causing SRI violations that are silent in smoke tests but catastrophic in real user sessions.
The diagram below shows where hash generation sits in the delivery pipeline: immediately after final asset transformation and before manifest emission, so every downstream consumer reads verified digests.
Prerequisites
Permalink to "Prerequisites"Conceptual Foundation
Permalink to "Conceptual Foundation"The W3C Subresource Integrity specification (Level 1, published 2016) defines the integrity attribute as a base64-encoded cryptographic digest prefixed by the algorithm name: sha384-<base64>. The browser fetches the resource, computes its own digest using the declared algorithm, and compares it to the declared value. A mismatch causes the network request to be blocked before the script is executed or the stylesheet is applied.
Every value your build pipeline emits has the same three-part shape, and knowing which part is which makes hash-drift debugging far quicker: an unexpected algorithm token points at plugin configuration, whereas an unexpected digest points at the bytes on disk.
The digest segment is standard base64 with the + and / alphabet and mandatory = padding, not the URL-safe variant that many CLI helpers emit by default. A SHA-384 digest is 48 raw bytes, which encodes to exactly 64 base64 characters with no padding character; SHA-256 produces 44 characters ending in a single =. If a generated value has the wrong length or contains - or _ inside the digest, the browser treats the whole attribute as unparseable and — per the specification — falls back to loading the resource with no integrity check at all rather than blocking it. The full encoding rules are covered in Base64 Encoding Rules for SRI Hashes.
For this mechanism to hold, the hash embedded in HTML must match the bytes the browser will receive — byte for byte. That requirement pushes cryptographic responsibility into the build pipeline. Hash values cannot be computed on source files; they must be computed on the exact artifact that will be served, after every transformation (minification, dead-code elimination, source map generation, content encoding) has completed. The SRI plugin hooks into the bundler’s asset-emit phase, which runs after all transforms, to guarantee this ordering.
SHA-384 is the recommended algorithm for new projects. It produces a 48-byte digest (384 bits), providing a significantly larger security margin than SHA-256 without the output-length overhead of SHA-512. For algorithm comparison detail, see Understanding Cryptographic Hash Algorithms.
Step 1 — Configure Deterministic Build Output
Permalink to "Step 1 — Configure Deterministic Build Output"Non-deterministic chunk names cause hash drift between builds of identical source. Configure your bundler to derive filenames from content, not from module resolution order or build timestamps.
webpack 5:
// webpack.config.js
module.exports = {
mode: 'production',
output: {
filename: '[name].[contenthash:12].js',
chunkFilename: '[name].[contenthash:12].chunk.js',
assetModuleFilename: 'assets/[hash:12][ext]',
publicPath: '/dist/',
crossOriginLoading: 'anonymous',
},
optimization: {
moduleIds: 'deterministic',
chunkIds: 'deterministic',
},
};
Vite:
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
},
},
},
});
Expected output: two consecutive builds of the same source tree should produce identical filenames and file sizes. Verify with diff -r dist-build-1/ dist-build-2/ — any diff indicates non-determinism.
Step 2 — Install an SRI Plugin
Permalink to "Step 2 — Install an SRI Plugin"The plugin intercepts finalised asset buffers, computes SHA-384 digests, and injects integrity attributes into the HTML output or a manifest file.
webpack — webpack-subresource-integrity:
npm install --save-dev webpack-subresource-integrity html-webpack-plugin
// webpack.config.js
const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
output: {
crossOriginLoading: 'anonymous',
/* ...filename config from Step 1... */
},
plugins: [
new HtmlWebpackPlugin({ template: 'src/index.html' }),
new SubresourceIntegrityPlugin({
hashFuncNames: ['sha384'],
enabled: process.env.NODE_ENV === 'production',
}),
],
};
The enabled flag prevents hash computation during development hot-reload, where rapid asset churn would cause constant SRI violations.
Vite — vite-plugin-subresource-integrity:
npm install --save-dev vite-plugin-subresource-integrity
// vite.config.js
import { defineConfig } from 'vite';
import sri from 'vite-plugin-subresource-integrity';
export default defineConfig({
plugins: [
sri({ algorithms: ['sha384'] }),
],
});
Expected output: the emitted HTML will contain <script> and <link> tags with integrity="sha384-..." and crossorigin="anonymous" attributes.
Step 3 — Emit an Asset Manifest
Permalink to "Step 3 — Emit an Asset Manifest"Static HTML injection is sufficient for apps that load all assets from a single HTML entry point. Applications that dynamically inject scripts — SPAs, micro-frontends, module federation hosts — require a manifest that maps asset paths to their integrity hashes so runtime code can construct the integrity attribute before injection.
// webpack.config.js — emit a manifest alongside HTML
const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity');
const WebpackManifestPlugin = require('webpack-manifest-plugin').WebpackManifestPlugin;
module.exports = {
plugins: [
new SubresourceIntegrityPlugin({ hashFuncNames: ['sha384'] }),
new WebpackManifestPlugin({
generate: (seed, files) => {
return files.reduce((manifest, file) => {
manifest[file.name] = {
src: file.path,
integrity: file.chunk?.contentHash?.['sha384']
? `sha384-${file.chunk.contentHash['sha384']}`
: undefined,
};
return manifest;
}, seed);
},
}),
],
};
The resulting manifest.json feeds Dynamic Script Loading Patterns at runtime, so dynamically injected chunks carry the same cryptographic guarantees as the initial payload.
Step 4 — CLI Hash Generation for Legacy Pipelines
Permalink to "Step 4 — CLI Hash Generation for Legacy Pipelines"When a bundler plugin is not available — legacy Grunt/Gulp pipelines, pre-compiled vendor bundles, or third-party assets — compute hashes directly from the CLI.
# Single file
openssl dgst -sha384 -binary dist/vendor.js | openssl base64 -A | sed 's/^/sha384-/'
# All JS and CSS files in dist/
find dist -name '*.js' -o -name '*.css' | sort | while read f; do
hash=$(openssl dgst -sha384 -binary "$f" | openssl base64 -A)
echo " \"${f#dist/}\": \"sha384-${hash}\","
done
For batch manifest generation, the sri-toolbox package provides a Node API that outputs JSON directly:
// scripts/generate-sri-manifest.js
const sriToolbox = require('sri-toolbox');
const fs = require('fs');
const path = require('path');
const distDir = path.resolve(__dirname, '../dist');
const manifest = {};
const files = fs.readdirSync(distDir).filter(f => /\.(js|css)$/.test(f));
for (const file of files) {
const filePath = path.join(distDir, file);
const content = fs.readFileSync(filePath);
manifest[file] = sriToolbox.generate({ algorithms: ['sha384'] }, content);
}
fs.writeFileSync(
path.join(distDir, 'sri-manifest.json'),
JSON.stringify(manifest, null, 2)
);
console.log(`Generated SRI manifest for ${files.length} assets.`);
These three generation paths are not interchangeable, and most production builds end up running two of them side by side. A bundler plugin is the only option that can reach inside the runtime chunk loader, because it rewrites generated loader code that does not exist until the compilation finishes. A CLI loop or a standalone Node script, by contrast, is the only option that can hash pre-built vendor files that never pass through the bundler graph at all — a jQuery build copied into public/, a WASM binary produced by a separate toolchain, or a polyfill served straight from static/. The matrix below shows where each path covers you and where it silently leaves a gap.
The practical consequence is that the CLI and script paths must be scheduled explicitly. A bundler plugin runs whenever the build runs; a shell loop over dist/ only runs if someone wires it into the npm build script, and it will happily produce a stale manifest if it executes before the copy step that drops vendor files into the output directory. Order the npm scripts so hashing is always the last operation before packaging, and have the hashing step fail loudly when it finds zero files rather than writing an empty manifest.
Step 5 — CI/CD Gating
Permalink to "Step 5 — CI/CD Gating"A post-build verification step prevents corrupted or non-deterministic artifacts from reaching production. The script reads the emitted manifest, recomputes each hash from the actual file on disk, and exits non-zero on any mismatch.
// scripts/verify-sri-manifest.js
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const manifestPath = path.resolve(__dirname, '../dist/sri-manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
let failures = 0;
for (const [file, declared] of Object.entries(manifest)) {
const filePath = path.resolve(__dirname, '../dist', file);
if (!fs.existsSync(filePath)) {
console.error(`MISSING: ${file}`);
failures++;
continue;
}
const buf = fs.readFileSync(filePath);
const actual = 'sha384-' + crypto.createHash('sha384').update(buf).digest('base64');
if (actual !== declared) {
console.error(`MISMATCH: ${file}`);
console.error(` declared: ${declared}`);
console.error(` actual: ${actual}`);
failures++;
}
}
if (failures > 0) {
console.error(`\n${failures} integrity failure(s). Blocking deployment.`);
process.exit(1);
}
console.log(`All ${Object.keys(manifest).length} SRI hashes verified.`);
Wire this into your CI workflow:
# .github/workflows/sri-gate.yml
name: SRI Integrity Gate
on: [push, pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run build
- name: Verify SRI manifest
run: node scripts/verify-sri-manifest.js
- name: Upload manifest as artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: sri-manifest
path: dist/sri-manifest.json
Expected output: the job prints All 14 SRI hashes verified. (or whatever your asset count is) and uploads the manifest. A non-zero exit prints the offending filename together with both digests, which is usually enough to identify the cause without reproducing the build locally — a MISSING line means a file was renamed or never copied, while a MISMATCH line means the bytes changed after hashing.
Recomputing hashes from the same working directory that produced them only catches gross corruption. The stronger check runs the same verification against the artifacts as they exist after upload, fetching each URL from the deployment target and hashing the response body, so any transform applied by the CDN, the object store, or a compression middleware is caught before traffic is switched over. Building that second gate — including how to make a build fail on an unexpected digest rather than silently republish it — is covered in Failing CI on SRI Hash Drift.
Configuration Reference
Permalink to "Configuration Reference"| Option | Valid values | Security implication |
|---|---|---|
hashFuncNames (webpack plugin) |
['sha256'], ['sha384'], ['sha512'], ['sha384','sha256'] |
SHA-384 is the minimum recommended. Multiple values produce a multi-hash integrity attribute; browsers pick the strongest they support. |
crossOriginLoading (webpack output) |
'anonymous', 'use-credentials' |
Must be 'anonymous' for public CDN assets. Omitting this causes opaque responses; SRI check is skipped and the resource is blocked. |
enabled (webpack plugin) |
true / false |
Disable in development to avoid hot-reload SRI violations. Always true in production. |
algorithms (Vite plugin) |
['sha384'] |
Same recommendations as webpack. |
moduleIds / chunkIds (webpack optimization) |
'deterministic', 'named', 'natural' |
'deterministic' is required for reproducible hashes across builds. 'natural' order-depends on module resolution and will produce hash drift. |
Integration with Adjacent Tooling
Permalink to "Integration with Adjacent Tooling"The output of this workflow feeds directly into the next steps in the supply chain:
- CDN routing: The SRI manifest maps asset filenames to their digests. CDN Trust Mapping & Routing consumes this manifest to configure origin shielding policies so only verified byte sequences are served from edge nodes.
- Dynamic loaders: Runtime module loaders read the manifest to attach
integrityattributes before injecting<script>elements, as described in Step 3 above. - SBOM generation: The manifest can be ingested by Automated SBOM Generation tools to record asset provenance alongside package-level dependency data.
- CSP headers: Hash values from the manifest can be embedded in
script-srcdirectives. For configuration details see Configuring Content-Security-Policy with SRI.
Troubleshooting
Permalink to "Troubleshooting"Failed to find an integrity hash for chunk 'main' (webpack)
Cause: HtmlWebpackPlugin is injecting chunks before SubresourceIntegrityPlugin has run. Fix: ensure both plugins are listed in the correct order in plugins[] — SubresourceIntegrityPlugin must be declared before any plugin that reads compilation.assets.
SRI hashes differ between local build and CI build
Cause: different Node.js or npm versions produce different node_modules resolutions, which change the bundled output. Fix: pin node-version-file: .nvmrc in your CI workflow and use npm ci (not npm install) so the lockfile is respected. Pin the npm version itself with packageManager in package.json — a lockfile can be honoured differently by npm 9 and npm 10 when peer dependency resolution changes.
integrity attribute present but browser reports net::ERR_SRI_SIGNATURE_MISMATCH
Cause: a CDN or proxy layer is modifying the asset in transit (minification, gzip re-encoding, comment stripping, or query-string normalisation). Fix: disable CDN-side asset optimisation for SRI-protected files and verify the Content-Encoding header matches what was present at hash-computation time.
Dynamic import() chunks load without SRI in production
Cause: webpack-subresource-integrity patches the runtime chunk loader, but only when crossOriginLoading is set. If crossOriginLoading is omitted from output, the plugin cannot inject integrity checks into the runtime loader. Fix: add crossOriginLoading: 'anonymous' to output in webpack.config.js. The failure is silent — the chunks still load, they are simply unverified — so add an assertion to your build that greps the emitted runtime for the injected integrity table. SRI for Lazy-Loaded Chunks covers the loader patch in detail.
Failed to find a valid digest in the 'integrity' attribute for resource '…' with computed SHA-384 integrity '…'
Cause: the HTML and the assets came from different builds. This happens when a cached dist/ directory is reused across a rebuild, when a deploy replaces asset files while an earlier HTML revision is still being served from cache, or when the manifest is committed to version control and drifts from the output. Fix: deploy HTML and hashed assets as one atomic unit, keep the old asset revision available for at least one cache lifetime, and regenerate the manifest on every build instead of reading a checked-in copy. The browser helpfully prints the digest it actually computed, so comparing it against openssl dgst -sha384 -binary output on the deployed file identifies which side is stale.
openssl produces a different hash than the webpack plugin
Cause: the openssl command was run against the source file rather than the emitted, transformed artifact. Fix: always hash files from the dist/ directory after the full build completes, never from src/.
Vite dev server throws SRI violations for HMR updates
Cause: the plugin is enabled in development mode. Fix: gate the plugin on mode === 'production' in vite.config.js or use the apply: 'build' option if the plugin supports it.
Security Boundary
Permalink to "Security Boundary"Static asset hash generation guarantees the integrity of specific, enumerated build artifacts at the point of browser fetch. It does not:
- Protect against vulnerabilities in the source code of those assets (a malicious but deterministically compiled dependency passes SRI checks)
- Verify the provenance of npm packages used during the build — use Provenance Verification Workflows and Sigstore attestations for that layer
- Prevent server-side request forgery or data exfiltration from within a loaded script
- Cover assets loaded by third-party scripts after page load — those fetches are outside the original SRI enforcement context
SRI hash generation is one control in a layered supply chain defence. Pair it with lockfile pinning, Browser Enforcement & Security Boundaries enforcement, and runtime telemetry for a complete posture.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Why does SRI verification pass in development but fail in production?
Non-deterministic build environments are the leading cause. Differences in Node.js version, locale, compression level, or source-map generation between environments change the final byte sequence, producing a different hash. Development servers also serve unminified, untransformed modules, so the bytes the browser receives never resemble the production artifact. Lock every build variable, run the production build in CI, and enforce identical toolchain versions across environments.
Does webpack-subresource-integrity work with code splitting?
Yes. The plugin hooks into webpack’s processAssets phase and computes SHA-384 digests on every emitted chunk after all transformations complete. It also patches the runtime chunk loader so dynamically imported chunks receive an integrity check before execution. That patch only applies when crossOriginLoading is set, so leaving it unset silently drops enforcement for lazy chunks while the initial bundle still verifies correctly.
Should I use SHA-256 or SHA-384 for SRI?
SHA-384 is the industry default for new deployments. It provides a stronger security margin than SHA-256 with negligible performance difference. SHA-512 is also valid but adds output length without meaningful extra protection. The W3C SRI specification allows multiple hash algorithms in a single integrity attribute, separated by spaces — browsers select the strongest one they support and ignore the rest.
What happens when a CDN modifies an asset after the hash is computed?
The browser blocks execution of the modified asset. Any byte-level change — comment injection, whitespace normalisation, HTML minification, or a change of compression format — alters the digest. This is the core protection SRI provides, and it cannot distinguish a hostile rewrite from a well-meaning optimisation. Configure your CDN to serve SRI-protected assets byte-for-byte identical to the origin output and disable edge transformation on those paths.
Can I generate SRI hashes without a bundler plugin?
Yes. The openssl dgst -sha384 -binary file.js | openssl base64 -A command generates the raw base64 digest; prefix it with sha384- to form a valid integrity value. For batch generation, use the sri-toolbox npm package or a short shell loop over your dist directory. The critical constraint is ordering: run the command against the built artifact, never the source file.
Where should the generated SRI manifest live once the build finishes?
Treat it as a build artifact, not a source file. Emit it into the same output directory as the assets it describes, upload it alongside them, and publish it as a CI artifact so a later deployment job can re-verify the files it actually pushed. Never commit it to version control — a committed manifest drifts from the build output within one merge and turns every hash check into a false failure.
Related
Permalink to "Related"- CI/CD Integrity Gates — turning the manifest produced here into a pipeline gate that blocks deploys on hash drift
- Automating Hash Generation in Webpack 5 — deep-dive into webpack-specific configuration, code splitting, and federation scenarios
- Dynamic Script Loading Patterns — runtime manifest consumption and integrity-checked dynamic
import()patterns - CDN Trust Mapping & Routing — propagating build-time digests to edge routing and origin shielding policies
- Understanding Cryptographic Hash Algorithms — SHA-256 vs SHA-384 vs SHA-512 algorithm selection rationale