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.

Static asset hash generation pipeline Data flow from source files through bundler transforms, the SRI plugin computing SHA-384 digests, manifest emission, CDN edge distribution, and final browser integrity verification. Source Files Bundler Transforms SRI Plugin SHA-384 digest on final buffer Asset Manifest Browser Verify emit JSON CDN

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.

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.`);

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

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 integrity attributes before injecting <script> elements — detailed in Dynamic Script Loading Patterns.
  • 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-src directives. 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. See Dependency Pinning Best Practices for lockfile enforcement strategies.

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.

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.

Permalink to "Related"

Articles in This Topic

Automating Hash Generation in Webpack 5
Generating SRI Hashes in Vite
Back to Asset Hashing & Dynamic Script Injection