SRI for Lazy-Loaded Chunks

Permalink to "SRI for Lazy-Loaded Chunks"

Part of Dynamic Script Loading Patterns, this page covers the hardest case in the family: chunks whose URLs are decided by a bundler runtime while the page is already running, so no author-written HTML ever gets a chance to declare a digest for them.

Quick Reference

Permalink to "Quick Reference"
Mechanism Where the digest lives Algorithm Support
SubresourceIntegrityPlugin (webpack 5) Hash table compiled into the runtime chunk sha384 via hashFuncNames Any browser with SRI; needs output.crossOriginLoading
<link rel="modulepreload" integrity> HTML, ahead of the import() sha384 Uneven across engines; verify per browser
Import map integrity key Inline import map in the document sha384 Chromium-based browsers; not yet universal
Post-build manifest + custom loader sri-manifest.json fetched at runtime sha384 Works anywhere integrity works
crypto.subtle.digest in a loader Manifest plus an explicit compare SHA-384 Secure contexts only; needs blob: in CSP

The integrity attribute is only honoured on elements the browser fetches in CORS mode, so every mechanism above also depends on crossorigin="anonymous" and a permissive Access-Control-Allow-Origin on the chunk origin.

The mental model

Permalink to "The mental model"

A statically declared <script src="/app.js" integrity="sha384-…" crossorigin="anonymous"></script> works because the author knew the URL and the bytes at build time and wrote both facts into the same tag. Code splitting deliberately destroys that arrangement. When you write const panel = await import('./admin-panel.js'), the bundler rewrites the call into a runtime request for a file whose name it will only finalise at the end of compilation, and whose URL is assembled at execution time from a public path plus a chunk id plus a content hash. Nothing in your source, and nothing in the emitted HTML, names that file.

There is also no attribute surface. import() is an expression that takes exactly one argument in every shipping engine, and while the specification has an options-bag second argument, that argument carries import attributes for module type — it is not a place to put a digest. The same is true of the internal request the bundler makes on your behalf: webpack’s chunk loader calls document.createElement('script'), and unless something tells it what digest to set, it sets none.

So integrity for lazy chunks is always the same shape: a table mapping chunk URL to digest, produced at build time, and a loader that reads the table and applies the digest before the fetch begins. The security of the whole arrangement then collapses onto one question — who guarantees the table? If the table ships inside a file that itself arrives unverified, an attacker who can rewrite one chunk can rewrite the table in the same breath, and every digest in it will match the tampered bytes perfectly. That is why the runtime chunk has to be the trusted root: it is the one file loaded from HTML you control, with a digest you wrote by hand or had a build plugin write, and hashing it is the single act that makes every hash it carries meaningful.

Chain of trust for lazy-loaded chunks The HTML document carries an integrity attribute for the runtime chunk; the verified runtime chunk holds a table of chunk digests, which it applies to each lazy chunk it loads, so an altered chunk is blocked. index.html integrity attr set verified runtime chunk = trusted root holds hash table lazy chunk A digest matches lazy chunk B digest matches altered chunk C blocked, no exec

Which mechanism you reach for is decided almost entirely by what emits your chunks. Webpack has a maintained plugin that does the whole job. Vite and Rollup do not, so you assemble the table yourself and interpose a loader. Native module graphs with no bundler at all are best served by the import map, since that is the one place the module loader will look without being asked.

Choosing an integrity mechanism for lazy chunks A decision tree branching from a runtime-decided chunk URL into four cases: webpack 5 uses the SRI plugin, Vite or Rollup uses a post-build manifest and loader, native modules use the import map integrity key, and builds with no hook fall back to a SubtleCrypto digest compare. chunk URL chosen at runtime no attribute to write on webpack 5 emits the chunks Vite or Rollup emits the chunks native modules, no bundler no build hook available to you SubresourceIntegrity Plugin plus crossOriginLoading post-build SRI manifest plus a custom loader import map integrity key fetch plus SubtleCrypto digest compare

Canonical example: webpack 5 with hashes in the runtime chunk

Permalink to "Canonical example: webpack 5 with hashes in the runtime chunk"

This is the complete production configuration. It does three things at once: it forces a single, separate runtime chunk so the trusted root is one small stable file, it puts chunk requests into CORS mode, and it compiles a SHA-384 digest for every emitted chunk into that runtime.

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

module.exports = {
  mode: 'production',
  devtool: 'source-map', // never an eval-based devtool: it breaks digests
  output: {
    path: __dirname + '/dist',
    publicPath: '/static/',
    filename: '[name].[contenthash].js',
    chunkFilename: '[name].[contenthash].js',
    // Makes the runtime add crossorigin="anonymous" to injected script/link tags.
    crossOriginLoading: 'anonymous',
  },
  optimization: {
    // One small runtime chunk that every entry shares: this is the trusted root.
    runtimeChunk: 'single',
  },
  plugins: [
    new HtmlWebpackPlugin({ template: 'src/index.html' }),
    new SubresourceIntegrityPlugin({
      hashFuncNames: ['sha384'],
      enabled: true,
    }),
  ],
};

Install it with npm install --save-dev webpack-subresource-integrity. At compile time the plugin walks the emitted assets, computes a digest per chunk, and rewrites the chunk-loading routine in the runtime so that the <script> element it builds for a lazy chunk gets an integrity property set from that table before it is appended to the document. Because the plugin also hooks html-webpack-plugin, the entry and runtime tags in the generated HTML come out already annotated:

<script
  src="/static/runtime.9a1f4c2e.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  crossorigin="anonymous"
  defer
></script>

That tag is the entire security argument. The browser verifies the runtime chunk against a digest written into HTML that you serve, and only then does the runtime’s table of chunk digests become trustworthy. Generate the digest in the same build that generates the file — the mechanics of that are covered in Automating Hash Generation in Webpack 5.

Your application source is unchanged. A plain dynamic import still reads as a plain dynamic import:

// src/routes.js — no integrity plumbing visible at the call site
export async function openAdminPanel(container) {
  const { mount } = await import(
    /* webpackChunkName: "admin-panel" */ './admin-panel.js'
  );
  return mount(container);
}

When that import fires, the runtime builds a script element for /static/admin-panel.<contenthash>.js, sets crossOrigin and integrity, appends it, and waits. If the bytes on the CDN no longer match, the element fires error, the chunk-load promise rejects, and your await throws — with the browser’s digest failure printed to the console.

Lazy chunk load sequence with integrity Application code calls dynamic import, the bundler runtime sets integrity and crossorigin on a script element, the browser issues a CORS request to the CDN, the response is digested with SHA-384 and compared to the runtime table, and the import promise then resolves or rejects. app code runtime chunk browser fetch CDN origin import('./panel') set integrity + crossorigin CORS GET of chunk 200 + ACAO header compute SHA-384 compare to table promise resolves, module evaluates mismatch: promise rejects, nothing runs

Variants

Permalink to "Variants"

Vite and Rollup: hash the output, then interpose a loader

Permalink to "Vite and Rollup: hash the output, then interpose a loader"

Neither Vite nor Rollup writes integrity attributes into the preload helper that fronts a dynamic import, and build.manifest produces a file map with no digests in it. The reliable approach is to hash the emitted files after they are on disk — that guarantees you digest exactly the bytes you will serve, including anything a post-processing step touched — and to publish that table as its own asset.

// scripts/sri-manifest.mjs — run after `vite build`
import { createHash } from 'node:crypto';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join, posix, relative } from 'node:path';

const DIST = 'dist';
const BASE = '/assets/';

async function* walk(dir) {
  for (const entry of await readdir(dir, { withFileTypes: true })) {
    const full = join(dir, entry.name);
    if (entry.isDirectory()) yield* walk(full);
    else yield full;
  }
}

const table = {};
for await (const file of walk(join(DIST, 'assets'))) {
  if (!file.endsWith('.js')) continue;
  const digest = createHash('sha384').update(await readFile(file)).digest('base64');
  const url = posix.join(BASE, relative(join(DIST, 'assets'), file).split(/[\\/]/).join('/'));
  table[url] = `sha384-${digest}`;
}

await writeFile(join(DIST, 'sri-manifest.json'), JSON.stringify(table, null, 2));
console.log(`wrote ${Object.keys(table).length} digests`);

The loader then reads that table and declares the digest before the import runs, using a modulepreload link so the module map is populated by a verified fetch:

// src/sri-preload.js
let manifestPromise;

function manifest() {
  manifestPromise ??= fetch('/sri-manifest.json', { credentials: 'omit' })
    .then((r) => {
      if (!r.ok) throw new Error(`[sri] manifest HTTP ${r.status}`);
      return r.json();
    });
  return manifestPromise;
}

export async function preloadVerified(chunkUrl) {
  const table = await manifest();
  const hash = table[chunkUrl];
  if (!hash) throw new Error(`[sri] no digest for ${chunkUrl} — refusing to load`);

  const link = document.createElement('link');
  link.rel = 'modulepreload';
  link.href = chunkUrl;
  link.integrity = hash;
  link.crossOrigin = 'anonymous';
  document.head.appendChild(link);
}

The manifest is now the trusted root, so it must be served same-origin with a short cache lifetime, and the script that fetches it must itself be an entry file carrying an integrity attribute in your HTML. Producing digests inside the build rather than after it is possible through a Rollup plugin’s generateBundle hook, which receives the final chunk code; that route is covered in Adding SRI to Rollup and esbuild Builds, and the Vite-specific configuration in Generating SRI Hashes in Vite.

modulepreload declared in HTML, and its caveat

Permalink to "modulepreload declared in HTML, and its caveat"

For a chunk you know will be needed — the route the user is about to reach, a dialog behind a hover — you can declare the preload statically:

<link
  rel="modulepreload"
  href="/assets/admin-panel.6f2b1ca4.js"
  integrity="sha384-Pz9dFT0nvQ2fyR0DEQ0K0M2Xk9pXd1oB2p6d3xWzxJcJmvJfHXTqRJx3nGyRc9tK"
  crossorigin="anonymous"
/>

If the preload succeeds, the module is in the map by the time import() asks for it and no second network request happens. The caveat is real and worth stating plainly: integrity on modulepreload links is not implemented consistently across engines, and the behaviour after a digest failure — whether the failed entry poisons the module map or the engine simply refetches without a digest — has varied between releases. Treat it as a hardening layer that also buys you a latency win, confirm it in each browser you support, and keep a mechanism underneath it that fails closed. Where a native module graph is the whole story, the import map is the more dependable route; see Using the Import Map integrity Key and the broader treatment in SRI for ES Module Imports.

Verify with SubtleCrypto in a custom loader

Permalink to "Verify with SubtleCrypto in a custom loader"

When no build hook is available to you — a chunk produced by a vendor, a plugin bundle dropped into a directory, an environment where you cannot alter the HTML — you can do the comparison yourself. This fails closed by construction, because the module is never handed to the engine until the digest matches.

// src/verified-import.js
const toBase64 = (buf) =>
  btoa(String.fromCharCode(...new Uint8Array(buf)));

export async function importVerified(url, expected) {
  const res = await fetch(url, { credentials: 'omit', cache: 'no-store' });
  if (!res.ok) throw new Error(`[sri] ${url} → HTTP ${res.status}`);

  const bytes = await res.arrayBuffer();
  const actual = `sha384-${toBase64(await crypto.subtle.digest('SHA-384', bytes))}`;
  if (actual !== expected) {
    throw new Error(`[sri] digest mismatch for ${url}\n  expected ${expected}\n  actual   ${actual}`);
  }

  const objectUrl = URL.createObjectURL(
    new Blob([bytes], { type: 'text/javascript' }),
  );
  try {
    return await import(/* @vite-ignore */ objectUrl);
  } finally {
    URL.revokeObjectURL(objectUrl);
  }
}

Three constraints come with it. crypto.subtle exists only in secure contexts, so this will not work over plain HTTP outside localhost. Your Content Security Policy must list blob: in script-src, which widens the policy slightly. And relative import specifiers inside the fetched module resolve against the blob URL, not the original path, so this technique is limited to chunks that are self-contained or that import only bare specifiers your import map resolves. For the general loader-factory pattern this builds on, see Implementing Dynamic Script Loaders with Integrity.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Omitting crossorigin silently disables verification. A script fetched in no-cors mode yields an opaque response the browser cannot read, so it cannot digest it; the request is failed rather than verified. Every element carrying an integrity attribute needs crossorigin="anonymous", and the chunk origin needs a matching Access-Control-Allow-Origin. In webpack this is exactly what output.crossOriginLoading: 'anonymous' buys you, and the mechanics are unpacked in How CORS and crossorigin Affect SRI.

  • An unverified runtime chunk makes the whole table decorative. If the runtime is inlined into HTML you serve dynamically, or loaded with a digest of its own, the chain holds. If it is loaded as a plain <script src> from the same CDN as the chunks, an attacker with write access to that CDN rewrites the table and the chunk in one operation and every comparison passes.

  • Eval-based source maps and hot module replacement break digests. devtool: 'eval-source-map' and the dev server rewrite module bodies after hashing, so digests computed at compile time no longer describe what is served. Enable the plugin only for production builds, and use a non-eval devtool.

  • Build-wide hash placeholders cause phantom mismatches. [contenthash] gives each chunk a filename derived from its own bytes. [hash] derives from the whole compilation, so one unchanged chunk can be republished under a new name, or worse, a changed chunk can reuse a URL a proxy has already cached. Users then hold bytes that no longer match the digest and the route simply refuses to load.

  • A rejected chunk import is an unhandled rejection unless you catch it. Integrity failure surfaces as a rejected promise from import(), not as a global error you can ignore. Wrap lazy route loads and decide what the user sees — a retry against a second origin, a full reload, or a degraded view. Patterns for that are in Handling SRI Failures with onerror Handlers.

Verification Steps

Permalink to "Verification Steps"

1. Confirm digests actually reached the runtime chunk

Permalink to "1. Confirm digests actually reached the runtime chunk"
grep -oE 'sha384-[A-Za-z0-9+/]{64}' dist/runtime.*.js | sort -u | head -5

Expected output is one sha384- string per lazily loaded chunk. An empty result means the plugin ran but produced nothing — almost always because output.crossOriginLoading was left unset or enabled resolved to false outside production mode.

2. Confirm the injected element carries both attributes

Permalink to "2. Confirm the injected element carries both attributes"

Load a route that triggers a lazy import, then run this in the browser console:

[...document.querySelectorAll('script[src*="admin-panel"]')]
  .map((s) => ({ src: s.src, integrity: s.integrity, cors: s.crossOrigin }));

Expected output:

[{ src: "https://cdn.example.com/static/admin-panel.6f2b1ca4.js",
   integrity: "sha384-Pz9dFT0nvQ2fyR0DEQ0K0M2Xk9pXd1oB2p6d3xWzxJcJmvJfHXTqRJx3nGyRc9tK",
   cors: "anonymous" }]

An empty integrity here is the single most common failure, and it means the chunk loaded with no verification at all.

3. Prove tampering is blocked

Permalink to "3. Prove tampering is blocked"

Append one byte to a deployed chunk in a staging bucket, then reload the route:

printf '\n// tamper' >> dist/admin-panel.6f2b1ca4.js

Expected console output in Chromium, with the import promise rejecting:

Failed to find a valid digest in the 'integrity' attribute for resource
'https://cdn.example.com/static/admin-panel.6f2b1ca4.js' with computed
SHA-384 integrity 'Fh1c9Xj…'. The resource has been blocked.

If the chunk executes anyway, the digest never made it onto the element — go back to step 2. Reading these messages in detail is covered in Debugging SRI Hash Mismatch Errors.

4. Re-hash what is actually deployed

Permalink to "4. Re-hash what is actually deployed"

Digests computed in CI describe artefacts in CI. Confirm the edge is serving the same bytes:

curl -s https://cdn.example.com/static/admin-panel.6f2b1ca4.js \
  | openssl dgst -sha384 -binary | openssl base64 -A

The output must equal the base64 portion of the digest in your runtime chunk or manifest. Wire this comparison into the pipeline so a rewriting proxy or a stale bucket is caught before users are; see Failing CI on SRI Hash Drift.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Why can a dynamic import() not carry an integrity attribute?

import() is a JavaScript expression, not an HTML element, so there is no attribute surface to hang a digest on. The specifier resolves to a URL and the module is fetched by the module loader with no per-call integrity parameter. Integrity has to come from somewhere the loader already consults: an import map integrity entry, a modulepreload link, or a bundler runtime that creates script elements itself.

Does output.crossOriginLoading on its own add integrity to lazy chunks?

No. crossOriginLoading only makes the webpack runtime set a crossorigin attribute on the script and link elements it injects, which puts the request in CORS mode. That is a precondition for SRI, not SRI itself. Without a plugin writing digests into the runtime, no integrity attribute is ever set and nothing is verified.

What happens if the runtime chunk itself is tampered with?

Every chunk digest becomes worthless, because the attacker controls the table the comparison is made against. The runtime chunk is the trusted root of the whole scheme, so it must be loaded from HTML with its own integrity attribute and crossorigin=anonymous, or inlined into a document already covered by a hash-based Content Security Policy.

Can a modulepreload link protect a later dynamic import?

It can, when the preload lands in the module map before the import runs and the engine reuses that entry. Support for integrity on modulepreload links and the exact behaviour after a digest failure still differ between engines, so treat it as a hardening layer and test it in every browser you support rather than relying on it as the only control.

Why does webpack-subresource-integrity insist on contenthash filenames?

A digest is only stable if the filename changes whenever the bytes change. With [contenthash] a modified chunk gets a new URL and a new hash, so caches and hash tables stay consistent. With a build-wide hash placeholder, two different builds can reuse one URL with different content, which produces mismatch errors for users holding a cached copy.

Permalink to "Related"

Related Articles

Implementing Dynamic Script Loaders with Integrity
Adding Integrity to Runtime-Injected Scripts
Dynamic Script Loading Patterns Asset Hashing & Dynamic Script…