Dynamic Script Loading Patterns

Permalink to "Dynamic Script Loading Patterns"

Runtime script injection is a standard optimization technique — lazy-loading analytics, A/B testing libraries, or feature-flagged modules keeps initial bundles lean. But every document.createElement('script') call that omits an integrity attribute is an unsigned execution boundary: a single compromised CDN node, a BGP hijack, or a cache-poisoning attack can substitute arbitrary JavaScript with no browser-level signal that anything went wrong. This page covers the complete workflow for injecting scripts at runtime inside Asset Hashing & Dynamic Script Injection — from generating a build-time SRI manifest through to CSP nonce wiring, runtime loader implementation, CI/CD gating, and production troubleshooting.

Prerequisites

Permalink to "Prerequisites"

Conceptual foundation

Permalink to "Conceptual foundation"

When a script element is appended to the DOM, the browser initiates a network fetch. If the element carries an integrity attribute, the browser computes a cryptographic digest of the downloaded bytes (using the algorithm prefix — sha256-, sha384-, or sha512-) and compares it against the base64-encoded value in the attribute. A mismatch causes the script to be blocked and the element’s onerror handler to fire. Critically, this verification happens at fetch time regardless of whether the element was present in the original HTML or injected by JavaScript at runtime.

The W3C Subresource Integrity specification (Level 1, published 2016) defines this mechanism in terms of the fetch algorithm’s “check SRI for response” step. For SHA-384 — the algorithm this site recommends for SRI — the digest is 48 bytes (384 bits), encoded to 64 base64 characters. The browser does not execute the response body until the digest comparison succeeds. This makes the integrity attribute the only browser-native guard against script substitution after the content leaves its origin.

One subtlety matters for dynamic loaders: crossorigin="anonymous" is mandatory on cross-origin script elements. Without it, the browser performs a no-credentials fetch that returns an opaque response — opaque responses have no readable body for digest comparison, so SRI enforcement silently fails and the script is blocked (or, in some browsers, executed without verification depending on the CSP posture). This is the single most common bug in dynamic SRI implementations.

Step 1 — Generate the SRI manifest at build time

Permalink to "Step 1 — Generate the SRI manifest at build time"

The loader needs a machine-readable map from each script filename to its SHA-384 digest. Emit this map as a JSON file at build time and deploy it alongside your assets.

Vite plugin (add to vite.config.ts):

import { createHash } from 'crypto';
import { readdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import type { Plugin } from 'vite';

function sriManifestPlugin(): Plugin {
  return {
    name: 'sri-manifest',
    apply: 'build',
    closeBundle() {
      const outDir = join(process.cwd(), 'dist', 'assets');
      const manifest: Record<string, string> = {};

      readdirSync(outDir).forEach(file => {
        if (!file.endsWith('.js') && !file.endsWith('.css')) return;
        const content = readFileSync(join(outDir, file));
        const hash = createHash('sha384').update(content).digest('base64');
        manifest[file] = `sha384-${hash}`;
      });

      writeFileSync(
        join(process.cwd(), 'dist', 'sri-manifest.json'),
        JSON.stringify(manifest, null, 2)
      );
      console.log(`[sri-manifest] wrote ${Object.keys(manifest).length} entries`);
    }
  };
}

export default {
  plugins: [sriManifestPlugin()]
};

Expected output after vite build:

{
  "vendor-BHgE3rqo.js": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho+White4=",
  "index-Cz2n5mJK.js": "sha384-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789abcdefghijklmnopqrstuvwxyz==",
  "main-D1e2f3g4.css": "sha384-XyZaBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPqRsTuVwXyZaBcD=="
}

For webpack, the Automating Hash Generation in Webpack 5 guide covers an equivalent approach using a custom WebpackManifestPlugin configuration.

CI verification signal: after build, run a diff between the manifest and the actual file hashes:

node -e "
const manifest = JSON.parse(require('fs').readFileSync('dist/sri-manifest.json'));
const { createHash } = require('crypto');
let ok = true;
for (const [file, expected] of Object.entries(manifest)) {
  const actual = 'sha384-' + createHash('sha384')
    .update(require('fs').readFileSync('dist/assets/' + file))
    .digest('base64');
  if (actual !== expected) { console.error('MISMATCH', file); ok = false; }
}
if (!ok) process.exit(1);
console.log('All SRI hashes verified.');
"

Step 2 — Wire CSP nonces and strict-dynamic

Permalink to "Step 2 — Wire CSP nonces and strict-dynamic"

For dynamically injected scripts to execute, the page’s Content Security Policy must allow them. A URL allowlist approach (script-src https://cdn.example.com) does work but requires updating the policy every time a CDN origin changes. The better approach combines a server-generated nonce on the bootstrap script with the strict-dynamic directive — the browser then allows any script programmatically created by a nonce-bearing script, without needing explicit CDN URLs.

Full CSP configuration, including the report-uri directive for Configuring Content-Security-Policy with SRI coverage, requires server-side nonce generation on every request.

Nginx — generate a nonce per request using $request_id:

# /etc/nginx/conf.d/csp.conf
map $request_id $csp_nonce {
  default $request_id;
}

server {
  listen 443 ssl http2;

  set $csp "script-src 'nonce-$csp_nonce' 'strict-dynamic' 'unsafe-inline' https:; ";
  set $csp "${csp}object-src 'none'; base-uri 'self'; ";
  set $csp "${csp}report-uri /csp-report";

  add_header Content-Security-Policy $csp always;
  add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
  add_header X-Content-Type-Options "nosniff" always;

  # Pass nonce to app via header so the template can embed it in the bootstrap script tag
  proxy_set_header X-CSP-Nonce $csp_nonce;
}

The 'unsafe-inline' token is ignored by browsers that support nonces, but kept for CSP Level 2 fallback compatibility. 'strict-dynamic' is also ignored in CSP Level 2 — scripts injected by the bootstrap script will not execute in those browsers unless their CDN URL is also in the allowlist.

Cloudflare Worker (preferred for edge-rendered pages):

export default {
  async fetch(request: Request): Promise<Response> {
    const nonce = crypto.randomUUID().replace(/-/g, '');
    const csp = [
      `script-src 'nonce-${nonce}' 'strict-dynamic' 'unsafe-inline' https:`,
      "object-src 'none'",
      "base-uri 'self'",
      "report-uri /csp-report"
    ].join('; ');

    const response = await fetch(request);
    const body = await response.text();

    // Inject nonce into the bootstrap script tag
    const modified = body.replace(
      '<script id="bootstrap"',
      `<script id="bootstrap" nonce="${nonce}"`
    );

    return new Response(modified, {
      headers: {
        ...Object.fromEntries(response.headers),
        'Content-Security-Policy': csp
      }
    });
  }
};

Step 3 — Implement the runtime loader

Permalink to "Step 3 — Implement the runtime loader"

The loader fetches the manifest, looks up the hash for each requested module, sets integrity and crossOrigin before DOM insertion, and handles failures through the onerror path. For a complete reference implementation see Implementing Dynamic Script Loaders with Integrity.

// src/loader.ts
type SriManifest = Record<string, string>;

let manifestCache: SriManifest | null = null;

async function fetchManifest(manifestUrl: string): Promise<SriManifest> {
  if (manifestCache) return manifestCache;
  const response = await fetch(manifestUrl, { credentials: 'same-origin' });
  if (!response.ok) throw new Error(`Manifest fetch failed: ${response.status}`);
  manifestCache = await response.json() as SriManifest;
  return manifestCache;
}

export async function loadScript(
  src: string,
  manifestUrl = '/sri-manifest.json'
): Promise<void> {
  const manifest = await fetchManifest(manifestUrl);

  // Extract the filename from the full src URL for manifest lookup
  const filename = src.split('/').pop() ?? src;
  const integrity = manifest[filename];

  if (!integrity) {
    throw new Error(`No SRI hash found for "${filename}" in manifest`);
  }

  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = src;
    script.integrity = integrity;
    script.crossOrigin = 'anonymous'; // mandatory: without this, CORS fetches return opaque responses
    script.defer = true;

    script.addEventListener('load', () => resolve());
    script.addEventListener('error', () => {
      reject(new Error(`SRI verification failed or network error for: ${src}`));
    });

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

Usage — lazy-loading a feature module:

// Triggered by user interaction, not at page load
document.getElementById('load-chart')?.addEventListener('click', async () => {
  try {
    await loadScript('https://cdn.example.com/assets/chart-D1e2f3g4.js');
    window.initChart({ target: '#chart-container' });
  } catch (err) {
    console.error('Chart module failed to load:', err);
    document.getElementById('chart-fallback')?.removeAttribute('hidden');
  }
});

Fallback strategy — when SRI verification fails, the onerror path must not silently swallow the failure. Emit a structured log entry and surface a graceful degradation to the user. The Handling SRI Failures with onerror Handlers page covers the full fallback architecture including telemetry patterns.

Step 4 — Configure CORS on the CDN

Permalink to "Step 4 — Configure CORS on the CDN"

SRI verification requires a CORS-enabled response for cross-origin fetches. Without Access-Control-Allow-Origin, the browser receives an opaque response and the digest comparison cannot proceed.

The crossOrigin = 'anonymous' attribute on the script element causes the browser to send an Origin request header. Your CDN must respond with Access-Control-Allow-Origin: * (or the specific origin) on the asset. Additionally, the asset must be served with Cache-Control: public, max-age=31536000, immutable — immutable caching prevents edge nodes from returning stale bytes after a hash rotation.

Cloudflare Workers route — attaching CORS and caching headers to versioned assets:

// workers/asset-headers.ts
export default {
  async fetch(request: Request): Promise<Response> {
    const response = await fetch(request);
    const url = new URL(request.url);

    // Only add CORS + immutable cache to content-hashed filenames
    const isVersioned = /\.[a-f0-9]{8,}\.(js|css|woff2)$/.test(url.pathname);
    if (!isVersioned) return response;

    return new Response(response.body, {
      status: response.status,
      headers: {
        ...Object.fromEntries(response.headers),
        'Access-Control-Allow-Origin': '*',
        'Cache-Control': 'public, max-age=31536000, immutable',
        'Vary': 'Origin'
      }
    });
  }
};

For full origin allowlisting and multi-CDN routing strategies, see CDN Trust Mapping & Routing which covers mapping CDN origins to SRI policies in detail.

Step 5 — Gate deployments in CI

Permalink to "Step 5 — Gate deployments in CI"

A manifest that passes hash verification at build time can still diverge from deployed artifacts if a deployment pipeline replaces files after the manifest is generated. Add an explicit mismatch simulation step to your CI workflow.

GitHub Actions — full SRI gating job:

# .github/workflows/sri-gate.yml
name: SRI Integrity Gate

on:
  pull_request:
  push:
    branches: [main]

jobs:
  sri-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci
      - run: npm run build

      - name: Verify SRI manifest hash parity
        run: |
          node scripts/verify-sri-manifest.js \
            --manifest dist/sri-manifest.json \
            --assets dist/assets
        # Exit code 1 blocks the deployment

      - name: Simulate SRI mismatch
        run: |
          # Corrupt a byte in a copy of one asset and verify the loader rejects it
          cp dist/assets/index-*.js /tmp/mutated.js
          printf '\x00' | dd of=/tmp/mutated.js bs=1 seek=100 conv=notrunc 2>/dev/null
          node scripts/verify-sri-manifest.js \
            --manifest dist/sri-manifest.json \
            --single-file /tmp/mutated.js \
            --expect-failure

The verify-sri-manifest.js script recomputes SHA-384 for every file listed in the manifest and exits non-zero on any mismatch, blocking the merge or deployment.

Configuration reference

Permalink to "Configuration reference"
Option Valid values Security implication
script.integrity sha256-<b64>, sha384-<b64>, sha512-<b64> SHA-384 recommended; SHA-256 is sufficient for most scenarios but SHA-512 provides the highest collision resistance
script.crossOrigin "anonymous", "use-credentials" "anonymous" required for public CDN assets; "use-credentials" for first-party cookie-bearing origins
CSP script-src nonce, 'strict-dynamic', origin allowlist 'strict-dynamic' removes the need to enumerate CDN origins at policy authoring time
Cache-Control on assets public, max-age=31536000, immutable immutable prevents edge revalidation of stale bytes; pair with content-hashed filenames
Manifest Cache-Control no-store or max-age=60 Manifests should be short-lived so clients pick up hash rotations within one TTL window
crossorigin on manifest <link rel="preload"> "anonymous" Preloading the manifest improves perceived load time; the attribute is required to avoid a double-fetch

Integration with the broader pipeline

Permalink to "Integration with the broader pipeline"

The output of this workflow feeds directly into several adjacent processes:

  • SBOM generation — each script filename + SHA-384 pair in the manifest becomes a component record in a CycloneDX SBOM. Tools like Automated SBOM Generation can ingest the manifest directly.
  • CSP reportingreport-uri violations from failed SRI checks appear in your violation log. Wire these into your Vulnerability Tracking & Triage pipeline to trigger incident response when mismatches appear in production.
  • Provenance records — the manifest hash can be included in the SLSA provenance attestation for the build, linking each runtime asset back to its source commit. See Provenance Verification Workflows for the attestation model.

Troubleshooting

Permalink to "Troubleshooting"
Error / symptom Root cause Fix
Failed to find a valid digest in the 'integrity' attribute for resource The computed digest does not match the integrity value on the script element Recompute the hash with openssl dgst -sha384 -binary <file> | openssl base64 -A and compare to the manifest entry; confirm no post-build transformation (e.g. gzip, Brotli re-encoding) altered the bytes
Script silently blocked, no console error Missing crossorigin="anonymous" on a cross-origin script — opaque response prevents digest comparison Always set script.crossOrigin = 'anonymous' before appending the element
Refused to load the script … because it violates the following Content Security Policy directive The injected script is not covered by the CSP — either the nonce is absent on the bootstrap script or 'strict-dynamic' is missing Verify the bootstrap script tag has a nonce attribute matching the Content-Security-Policy header; confirm 'strict-dynamic' is present in script-src
Manifest 404 at runtime The manifest was not included in the deployment artifact or is served at a different path Ensure your deployment step copies sri-manifest.json to the expected public path; serve it with Content-Type: application/json
Hash mismatch only on some CDN edge nodes Content-encoding inconsistency — some edges serve the file gzip-compressed while others do not, changing the byte sequence SRI hashes must be computed over the uncompressed bytes; confirm your CDN’s SRI behaviour with curl -H "Accept-Encoding: identity" to force uncompressed delivery
Manifest serves stale hashes after deployment Cache-Control on the manifest is too long Set Cache-Control: no-store or max-age=60 on the manifest endpoint; the manifest should never be aggressively cached

Security boundary note

Permalink to "Security boundary note"

This workflow verifies that dynamically injected scripts match a pre-computed hash — it does not:

  • Verify that the hash in the manifest was computed from audited, vulnerability-free source code. A manifest can faithfully describe a script that contains a supply-chain compromise introduced before the build. Pair this workflow with Dependency Pinning Best Practices and automated vulnerability scanning.
  • Protect against a compromised build environment that generates both the malicious script and a matching hash. Address this with reproducible builds and SLSA provenance attestation.
  • Enforce SRI on first-party scripts loaded via <script type="module"> dynamic import() — ES module dynamic imports do not yet use the integrity attribute. Use Import Maps with an integrity field for module-level enforcement.
  • Prevent an attacker who has write access to both the CDN and the manifest origin from substituting both simultaneously. The manifest must be served from a distinct, higher-trust origin than the script assets.

FAQ

Permalink to "FAQ"
Can I use SRI with dynamically injected script elements?

Yes. Set the integrity and crossOrigin attributes on the script element before appending it to the DOM. The browser performs the hash verification during the network fetch, regardless of whether the element was in the original HTML or injected at runtime.

Why does strict-dynamic matter for dynamic loaders?

A CSP script-src allowlist only covers URLs known at policy-authoring time. 'strict-dynamic' transfers trust from a nonce-bearing root script to any script it programmatically injects, so you do not need to predict every CDN URL in advance. Without it, every dynamically loaded CDN URL must appear explicitly in script-src.

What happens if the SRI manifest itself is tampered with?

If an attacker replaces the manifest, they can substitute arbitrary hashes that match their malicious scripts. Protect the manifest by serving it from your own origin (same-origin fetch) or from a CDN path locked by its own integrity attribute on a preload link tag.

Does SRI work for scripts loaded via import() or ES module workers?

Dynamic import() in module scripts does not yet support an integrity option in the same way as script elements. For module integrity enforcement use Import Maps with the integrity field (Chrome 95+ / Firefox 115+) or pre-fetch the module blob and verify it with SubtleCrypto before constructing an object URL.

How do I handle hash rotation without downtime?

Keep the old manifest version live for the duration of your CDN TTL (typically 5–15 minutes). Deploy the new manifest and scripts atomically; your loader fetches the manifest at runtime, so clients that already bootstrapped see the old version until they reload. Setting Cache-Control: max-age=60 on the manifest limits the staleness window.

Permalink to "Related"

Articles in This Topic

Implementing Dynamic Script Loaders with Integrity
Adding Integrity to Runtime-Injected Scripts
Back to Asset Hashing & Dynamic Script Injection