Implementing Dynamic Script Loaders with Integrity

Permalink to "Implementing Dynamic Script Loaders with Integrity"

Part of Dynamic Script Loading Patterns, this page covers exactly one topic: how to build a JavaScript loader factory that programmatically injects <script> elements with correct integrity and crossorigin attributes so the browser’s native SRI verifier runs on every dynamically loaded file.

Quick Reference

Permalink to "Quick Reference"
Attribute Required value Notes
integrity sha384-<base64-digest> Computed on the final minified artifact
crossorigin "anonymous" Required; omitting it causes an opaque response and blocks the script
type "module" or omit Module scripts enforce strict CORS; classic scripts also require CORS transparency for SRI
Browser support All evergreen browsers IE 11 does not support SRI; use feature detection if you must support it

Why a dedicated loader factory is necessary

Permalink to "Why a dedicated loader factory is necessary"

Browsers verify SRI only when two conditions are met: the integrity attribute is set before the element enters the DOM, and the fetch runs in CORS mode so the browser can read the response body. Ad-hoc script injection scattered across a codebase makes it trivial to miss either condition on one injection point. A centralized loader factory enforces both requirements at every call site and couples them to an integrity manifest produced at build time — closing the gap between what was audited at compile time and what actually executes at runtime.

The browser performs the cryptographic comparison; the loader’s job is solely to read the pre-computed digest from the manifest and attach it before DOM insertion. For a deep look at what happens inside the browser during that comparison, see Browser Enforcement & Security Boundaries.

Dynamic script loader data-flow Four boxes connected by arrows: Bundler emits integrity manifest, Loader reads manifest at runtime, createElement sets integrity + crossorigin, Browser verifies hash before execution. Bundler emits manifest Loader reads manifest createElement + integrity attr Browser verifies SHA-384 hash before script executes build time runtime

Canonical implementation

Permalink to "Canonical implementation"

This is a complete, production-ready loader factory. Copy it as-is and replace INTEGRITY_MANIFEST with however your application exposes the build-time manifest (inline JSON blob, a fetched endpoint, or a window.__SRI_MANIFEST__ injection).

// loader.js — integrity-first script loader factory

/**
 * Registry populated from the build-time integrity manifest.
 * Shape: { [publicPath: string]: string }   e.g. { "/app.js": "sha384-abc..." }
 */
const registry = Object.create(null);

/**
 * Populate the registry from a manifest object.
 * Call this before any loadScript() invocations.
 * @param {Record<string, string>} manifest
 */
export function initRegistry(manifest) {
  if (!manifest || typeof manifest !== 'object') {
    throw new Error('[sri-loader] Manifest is missing or malformed — aborting initialisation');
  }
  Object.assign(registry, manifest);
}

/**
 * Inject a <script> element with a verified integrity attribute.
 * @param {string} url  Absolute or root-relative URL matching a manifest key
 * @returns {Promise<void>}
 */
export function loadScript(url) {
  const hash = registry[url];
  if (!hash) {
    return Promise.reject(
      new Error(`[sri-loader] No integrity hash found for "${url}" — refusing to inject`)
    );
  }

  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = url;
    script.integrity = hash;           // e.g. "sha384-abc123..."
    script.crossOrigin = 'anonymous';  // mandatory: enables CORS mode for SRI verification
    script.defer = true;

    script.addEventListener('load', () => resolve());
    script.addEventListener('error', () =>
      reject(new Error(`[sri-loader] Load failed for "${url}" — possible SRI mismatch or network error`))
    );

    document.head.appendChild(script);
  });
}

Usage at application startup:

import { initRegistry, loadScript } from './loader.js';

// manifest.json is emitted by the bundler and served with immutable cache headers
const manifest = await fetch('/integrity-manifest.json').then(r => r.json());
initRegistry(manifest);

await loadScript('/chunks/analytics.abc123.js');
await loadScript('/chunks/vendor.def456.js');

The ordering inside loadScript() is the whole trick: src, integrity and crossOrigin are all assigned while the element is still detached, so the request that appendChild triggers leaves the browser already in CORS mode with a digest waiting for it. The verification itself happens entirely inside the browser after the response body arrives, which is why the loader never sees the bytes and never computes a hash of its own — it only ever resolves or rejects on the element’s load and error events.

Verification sequence for an injected script A sequence diagram with three lifelines. The loader calls appendChild, which makes the browser issue a CORS request to the CDN origin; the CDN returns the body with an Access-Control-Allow-Origin header; the browser computes the SHA-384 digest and compares it to the integrity attribute, then fires either a load event or an error event back at the loader. Loader code Browser CDN origin appendChild() starts fetch GET chunk, Origin sent 200 + ACAO header + body compute SHA-384 of body, compare to integrity value match: load event, script runs mismatch: error event, blocked

Variant examples

Permalink to "Variant examples" Permalink to "1. Module preloading with <link rel="modulepreload">"

For ES module graphs, modulepreload hints let the browser fetch and verify modules ahead of the import statement. The integrity attribute works identically here.

function preloadModule(url, hash) {
  const link = document.createElement('link');
  link.rel = 'modulepreload';
  link.href = url;
  link.integrity = hash;
  link.crossOrigin = 'anonymous';
  document.head.appendChild(link);
}

// Call early in the critical path, before the dynamic import fires
preloadModule('/modules/checkout.v2.js', 'sha384-XyzAbc...');

2. Webpack 5 manifest integration

Permalink to "2. Webpack 5 manifest integration"

Automating Hash Generation in Webpack 5 shows how webpack-subresource-integrity populates __webpack_require__.sri at build time. You can forward that directly into the registry without a separate manifest fetch:

// After webpack runtime initialises, __webpack_require__.sri contains
// { chunkId: "sha384-..." } entries. Remap to public URLs:
if (typeof __webpack_require__ !== 'undefined' && __webpack_require__.sri) {
  const mapped = {};
  for (const [chunkId, hash] of Object.entries(__webpack_require__.sri)) {
    const url = __webpack_require__.p + __webpack_require__.u(chunkId);
    mapped[url] = hash;
  }
  initRegistry(mapped);
}

3. Parallel loading with Promise.allSettled

Permalink to "3. Parallel loading with Promise.allSettled"

When multiple chunks share no dependency ordering, load them in parallel. Use Promise.allSettled rather than Promise.all so a single SRI failure does not prevent unrelated chunks from loading.

const chunks = [
  '/chunks/sidebar.js',
  '/chunks/tooltips.js',
  '/chunks/analytics.js',
];

const results = await Promise.allSettled(chunks.map(loadScript));

for (const result of results) {
  if (result.status === 'rejected') {
    console.error('[sri-loader] Chunk blocked:', result.reason.message);
    // report to telemetry — do not re-throw; let the app degrade gracefully
  }
}

When a script fails due to an SRI mismatch, pair this pattern with Handling SRI Failures with onerror Handlers to activate versioned fallback endpoints.

Gotchas and edge cases

Permalink to "Gotchas and edge cases"
  • crossOrigin = 'anonymous' is not optional. The browser needs a CORS-transparent response to read the body and compute the comparison hash. Without this attribute the response is opaque, the integrity check cannot proceed, and the browser blocks the script even if the hash is correct. This is the single most common real-world SRI failure; How CORS and crossorigin Affect SRI walks through the request and response headers that have to line up for the check to run at all.

  • Hash drift from post-build transforms. If a CDN edge function, a reverse proxy, or a service worker modifies the script body after your build computed the hash — even adding a byte-order mark or recompressing with a different deflate dictionary — the hash will not match. Compute hashes on the artifact that the CDN actually serves, and use content-hashed filenames (app.[contenthash].js) to guarantee immutability.

  • Setting integrity after appending to the DOM is too late. Browsers begin fetching as soon as the element enters the DOM. The integrity attribute must be set before appendChild or insertAdjacentElement is called.

  • strict-dynamic removes host allowlists for scripts loaded by trusted parents. When your CSP uses 'strict-dynamic', scripts loaded by a trusted parent script inherit trust propagation — but scripts loaded by loadScript() from untrusted call sites still need an explicit hash or nonce in script-src. Review your Configuring Content-Security-Policy with SRI policy to confirm which call sites are trusted.

  • Fallback URLs must also carry integrity checks. A fallback endpoint that bypasses the registry reintroduces the exact supply chain risk SRI is meant to close. Either serve fallbacks from a trusted local path or pre-register their hashes in the manifest under a separate key.

  • Module scripts and classic scripts behave differently under CORS. type="module" always fetches in CORS mode, so crossOrigin is redundant but harmless. Classic scripts without crossOrigin fetch without CORS and produce opaque responses — integrity verification silently fails.

Verification steps

Permalink to "Verification steps"

Chrome DevTools — confirm SRI is firing:

  1. Open DevTools → Network tab → click the injected script resource.
  2. In the Headers pane, confirm the response includes Access-Control-Allow-Origin.
  3. If the hash is intentionally wrong, the Console shows: Failed to find a valid digest in the 'integrity' attribute for resource 'https://cdn.example.com/app.js' with computed SHA-384 integrity '...'. If you see this with the correct hash, the CDN is mutating the response body.

CLI — verify CORS headers from the CDN:

curl -sI \
  -H "Origin: https://your-app.example.com" \
  "https://cdn.example.com/chunks/analytics.abc123.js" \
  | grep -i "access-control-allow-origin"
# Expected: access-control-allow-origin: *
# or:       access-control-allow-origin: https://your-app.example.com

If the header is absent, SRI will block every dynamically injected script regardless of hash correctness.

When an injected chunk simply never runs, work the three checks in a fixed order — transport, then bytes, then attribute ordering. Each answer eliminates a whole class of cause, and answering them out of order tends to send you chasing a hash mismatch that is really a missing response header.

Triage order for a blocked injected script A decision tree starting from an injected script that did not run. First check whether the response carries Access-Control-Allow-Origin; if not, the response is opaque. Then check whether the served body digest equals the manifest digest; if not, an edge transform rewrote the file. Then check whether integrity was assigned before appendChild; if not, the fetch started too early. If all three pass, audit the Content-Security-Policy script-src directive. Injected script did not run Access-Control-Allow-Origin present on the response? Served body digest equals the manifest digest? integrity assigned before appendChild() runs? Transport and ordering sound: audit CSP script-src next yes yes yes no Opaque response: the browser cannot read the body in order to hash it no An edge transform or proxy rewrote the file after the digest was taken no The fetch began before the attribute existed, so nothing was verified

CI hash consistency check:

# Recompute SHA-384 of the served artifact and compare against manifest
SERVED_HASH=$(curl -s "https://cdn.example.com/chunks/analytics.abc123.js" \
  | openssl dgst -sha384 -binary | base64 | tr -d '\n')

MANIFEST_HASH=$(jq -r '."/chunks/analytics.abc123.js"' integrity-manifest.json \
  | sed 's/sha384-//')

[ "$SERVED_HASH" = "$MANIFEST_HASH" ] \
  && echo "PASS: hash matches" \
  || echo "FAIL: hash mismatch — CDN is mutating the artifact"

Add this check to your deployment pipeline as a smoke test against each CDN edge region. Run the same comparison before the artifacts ship, too: Failing CI on SRI Hash Drift shows how to break the build when a rebuild produces a digest the committed manifest no longer pins, which catches drift before any browser ever blocks a chunk. For broader strategies on locking down which origins your application trusts at all, see Mapping CDN Origins to SRI Policies.


Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Does setting script.integrity without crossOrigin work?

No. The browser requires a CORS-transparent response to read the response body for hash comparison. Without crossOrigin = ‘anonymous’, the response is opaque and SRI blocks the resource regardless of whether the hash is correct.

Can I use a nonce instead of a hash for dynamically injected scripts?

Nonces cannot be applied to externally fetched resources via CSP alone — they apply to inline scripts. For external dynamic scripts, SRI hashes are the correct mechanism because they bind policy to specific file content.

What happens if the CDN serves a script that does not match the hash?

The browser blocks execution entirely, fires an error event on the script element, and emits a securitypolicyviolation event. The script is never evaluated.

Should the loader hash the file itself before injecting it?

No. Fetching the file, hashing it with SubtleCrypto and injecting a blob URL doubles the request, breaks caching, and verifies a copy that is not necessarily the one the script tag would have fetched. Set the integrity attribute and let the browser verify the response it actually executes, in the same fetch, before any evaluation happens.

Where should the integrity manifest itself be served from?

Serve it from your own origin over TLS, or inline it into the HTML document the server already renders. A manifest fetched from the same third-party CDN as the chunks it describes gives an attacker who controls that CDN both the file and the digest, which removes every guarantee SRI provides.

Permalink to "Related"

Related Articles

Adding Integrity to Runtime-Injected Scripts
SRI for Lazy-Loaded Chunks
Dynamic Script Loading Patterns Asset Hashing & Dynamic Script…