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 reporting —
report-uriviolations 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">dynamicimport()— ES module dynamic imports do not yet use theintegrityattribute. Use Import Maps with anintegrityfield 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.
Related
Permalink to "Related"- Implementing Dynamic Script Loaders with Integrity — complete reference implementation with TypeScript types and test harness
- Static Asset Hash Generation — how to generate and validate SHA-384 hashes in your build pipeline
- CDN Trust Mapping & Routing — mapping CDN origins to SRI enforcement policies
- Handling SRI Failures with onerror Handlers — full fallback architecture for production loaders