Load third-party scripts and stylesheets at runtime with Subresource Integrity enforced, fail-closed defaults, multi-CDN fallback chains and integrity-failure telemetry. Zero runtime dependencies.
Static markup makes integrity easy: you write <script src integrity crossorigin> once and a
reviewer can see the digest in the diff. Runtime injection is where it quietly disappears. A
consent manager, an A/B tool or a lazily loaded payment SDK builds a <script> element in
JavaScript, sets src, appends it, and ships. No integrity, no crossorigin, no linter
complaint, and the page now executes whatever that origin returns today. Worse, the near-miss
cases fail silently: an integrity attribute the browser cannot parse is ignored entirely, and an
attribute set after src never applies to a request already in flight. Both leave you with an
unverified load that looks pinned.
This library refuses to inject anything without integrity metadata unless you explicitly opt out, validates the digest before it touches the DOM, tries each source in a fallback chain in turn while retaining why every earlier one failed, and hands each failure to a telemetry hook so a compromised or drifted CDN shows up in your dashboards rather than in a user's browser.
It is a browser library. Everything it does is DOM injection plus optional fetch and WebCrypto.
Not published to npm. Take it one of three ways.
Vendor the minified build. Copy dist/sri-dynamic-loader.min.js into your static assets. It
is an IIFE exposing window.SRILoader:
<script src="/vendor/sri-dynamic-loader.min.js"></script>
<script>
SRILoader.loadScript('https://cdn.example/widget.js', { integrity: 'sha384-…' });
</script>Import the ESM bundle. dist/sri-dynamic-loader.js (readable) or
dist/sri-dynamic-loader.esm.min.js (minified) are single files with no imports:
<script type="module">
import { loadScript } from '/vendor/sri-dynamic-loader.esm.min.js';
</script>Clone and import the source. src/ is plain ES modules with no build step, which is the
easiest form to read and to patch:
$ git clone https://github.com/subresource-integrity/sri-dynamic-loader.git
$ cd sri-dynamic-loader
$ npm install # esbuild + eslint, both dev-only
$ npm run check # lint, test, buildTypeScript declarations live in types/index.d.ts and are referenced from package.json.
Measured sizes of the current build (npm run build prints this table and writes
dist/sizes.json):
$ npm run build
file raw gzip brotli
---------------------------------- ------- ------ ------
dist/sri-dynamic-loader.js 27295 B 7887 B 6854 B
dist/sri-dynamic-loader.min.js 15489 B 6203 B 5452 B
dist/sri-dynamic-loader.esm.min.js 14987 B 5994 B 5247 BA good share of that is error message text. Diagnostics are the point of the library, so they are
not tree-shaken away; if you only need the loader, importing from src/index.js through your own
bundler will drop whatever you do not reference.
import { loadScript } from './src/index.js';
await loadScript('https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js', {
integrity: 'sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs',
});Omit integrity and nothing is injected:
UnverifiedLoadError: refusing to load https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js
without integrity metadata. Pass an integrity value, or pass allowUnverified: true if this
resource genuinely cannot be pinned.
Pass a malformed one and it is rejected before the element exists, because a browser would have ignored it and loaded the script anyway:
IntegrityFormatError: integrity digest for sha384 decodes to 32 bytes, expected 48. A hex digest
pasted in place of base64 is the usual cause; SRI uses base64 of the raw binary digest.
Each source carries its own URL and its own digest, because a mirror is usually a different build with a different hash. Sources are tried in order and the first that loads wins.
const result = await loadScript([
{ url: 'https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js', integrity: 'sha384-1H21…' },
{ url: 'https://unpkg.com/jquery@3.7.1/dist/jquery.min.js', integrity: 'sha384-1H21…' },
{ url: '/vendor/jquery-3.7.1.min.js', integrity: 'sha384-9tPm…' },
]);
result.url; // the source that succeeded
result.attempts; // 3
result.failures; // the two errors from the CDNs, in orderIf every source fails you get one AllSourcesFailedError holding all of them, and
describe() renders the lot:
all 2 sources failed for this script
1. https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js
integrity-mismatch: failed to load https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js.
The response was fetched in full but the element still errored, which means the browser
rejected it after download. An integrity digest that does not match the bytes is by far the
most common cause; a missing or wrong crossorigin attribute produces the same symptom because
an opaque response can never satisfy an integrity check.
2. https://unpkg.com/jquery@3.7.1/dist/jquery.min.js
integrity-mismatch: failed to load https://unpkg.com/jquery@3.7.1/dist/jquery.min.js.
…Declare every pinned URL and digest in one file so rotating a hash is a one-line diff, and any
stray unpinned loadScript() elsewhere stands out in review. Definitions are validated eagerly:
a bad digest throws at startup, not the first time that script is needed.
import { defineSources, load } from './src/index.js';
defineSources({
jquery: [
{ url: 'https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js', integrity: 'sha384-1H21…' },
{ url: '/vendor/jquery-3.7.1.min.js', integrity: 'sha384-9tPm…' },
],
stripe: {
sources: [{ url: 'https://js.stripe.com/v3/', integrity: 'sha384-…', allowUnverified: false }],
options: { timeout: 8000 },
},
theme: {
kind: 'style',
sources: [{ url: 'https://cdn.example/theme.css', integrity: 'sha384-…' }],
},
});
await load('jquery');
await load('theme');await loadStyle('https://cdn.example/theme.css', { integrity: 'sha384-…', media: 'screen' });<link> load and error events are the least dependable part of this area: engines have
historically stayed silent for cached stylesheets, and several still fire nothing when a
stylesheet is rejected by the integrity check rather than firing error. So loadStyle also
polls link.sheet, which is populated only once the sheet has been fetched, integrity-checked and
parsed, and stays null when the check fails. A stylesheet that never applies therefore surfaces
as a LoadTimeoutError rather than hanging forever. Give stylesheet loads a timeout you are
happy to wait for.
An SRI mismatch reaches your code as a bare error event with no detail, identical to a DNS
failure — the browser will not say more, because saying more would be a cross-origin information
leak. verify: 'preflight' fetches the resource, hashes it with crypto.subtle, and compares
before anything is injected:
try {
await loadScript(url, { integrity: expected, verify: 'preflight' });
} catch (error) {
const mismatch = error.errors[0];
mismatch.reason; // 'integrity-mismatch'
mismatch.expected; // ['sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs']
mismatch.actual; // ['sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC']
}It costs a second request on a cold cache and it requires CORS and a secure context, so reserve it for the resources where a clear diagnosis is worth more than a few milliseconds.
await loadScript(sources, {
onViolation(details) {
navigator.sendBeacon('/telemetry/sri', JSON.stringify({
url: details.url,
reason: details.reason, // integrity-mismatch | csp-blocked | network | timeout | …
fatal: details.fatal, // true only when no source was left to try
position: `${details.sourceIndex + 1}/${details.sourceCount}`,
}));
},
});The hook fires once per failed source, including non-fatal ones, so a chain that silently falls back to a mirror every time still shows up. A throwing hook is swallowed: broken telemetry must not turn a recoverable CDN failure into an unhandled rejection.
$ npm run example
sri-dynamic-loader example: http://localhost:8000/
press ctrl-c to stopThe page demonstrates a successful pinned load, a deliberate hash mismatch falling back to a
mirror, preflight verification naming the expected and actual digests, a stylesheet load, the
fail-closed default, and the telemetry hook accumulating the lot. It has to be served over HTTP
rather than opened as a file:// URL: integrity, CORS and crypto.subtle all behave differently
on the file scheme, and localhost counts as a secure context.
Attribute order is load-bearing. The element is fully configured before src/href is set,
which is the last mutation in every path. Setting integrity or crossorigin afterwards has no
effect on a request already in flight, and that is a quiet way to lose enforcement entirely. The
test suite asserts that src is the final attribute written.
Validation before injection. The integrity string is parsed into algorithm/digest pairs, the
algorithm is checked against the three SRI defines (sha256, sha384, sha512 — not sha1, not
md5, not sha3), and the base64 is decoded to confirm it is the right length for that algorithm:
32, 48 or 64 bytes. A hex digest pasted where base64 belongs is the single most common mistake and
it is caught here. A browser given an unparseable integrity attribute treats the resource as
unpinned, so failing loudly at this point is the whole safety property.
Only the strongest algorithm counts. Browsers pick the strongest algorithm present in the
attribute and require at least one value of that algorithm to match; weaker values alongside it
are ignored. Preflight verification applies the same rule rather than accepting a sha256 the
browser would have discarded.
Failure diagnosis without extra traffic. Three signals narrow a bare error event:
securitypolicyviolationevents on the document. If CSP blocked the URL, an event names it, and that is a certain diagnosis — the integrity attribute was never evaluated.- Resource Timing. A resource that was fetched and then rejected by the integrity check still
produces a
PerformanceResourceTimingentry with a completed response; one that never connected does not. An entry withresponseEnd > 0plus anerrorevent means the bytes arrived and something downstream refused them, which is overwhelmingly an integrity mismatch — or a missingcrossorigin, which looks identical because an opaque response can never satisfy an integrity check. - Elapsed time, as a weak tiebreaker only.
Each error carries reason and a confidence of certain, likely or unknown, so nothing
overstates what the platform actually told us. When preflight has already proved the digest
correct, an error event is explicitly not blamed on integrity.
Deduplication. Loads are cached by kind plus absolute URL, so two concurrent calls for the
same script share one promise and one DOM element, and a URL that already succeeded resolves
immediately with cached: true. Failures are never cached, so a retry after a CDN recovers can
succeed and a fallback chain works. reload: true bypasses the cache.
Cleanup. Timeout and abort both remove the pending element from the document before rejecting. An abort stops the whole chain rather than advancing to the next mirror: it was the caller's decision, not a property of the source.
Set per call, or per source inside a chain, where per-source wins. Rows marked · may be set on an individual source.
| Option | · | Type | Default | Purpose |
|---|---|---|---|---|
integrity |
· | string |
— | SRI metadata, e.g. sha384-…. Space-separated values allowed. Required unless allowUnverified. |
allowUnverified |
· | boolean |
false |
Escape hatch for a resource that genuinely cannot be pinned. Without it, an unpinned load throws. |
crossOrigin |
· | 'anonymous' | 'use-credentials' | null |
'anonymous' |
crossorigin attribute. null omits it, which is refused for a cross-origin URL that has integrity. |
verify |
· | 'none' | 'preflight' |
'none' |
preflight hashes the bytes with WebCrypto before injecting. Requires CORS and a secure context. |
module |
· | boolean |
false |
Inject the script as type="module". |
nonce |
· | string |
— | CSP nonce, set as both content and IDL attribute. |
attributes |
· | Record<string, string> |
{} |
Extra attributes, written before src/href. |
referrerPolicy |
· | string |
— | referrerpolicy attribute. |
media |
· | string |
— | media attribute, stylesheets only. |
timeout |
number |
15000 |
Milliseconds before the element is removed and the load rejected. 0 disables it. |
|
signal |
AbortSignal |
— | Cancels the load and stops the chain. | |
reload |
boolean |
false |
Bypass the dedupe cache and inject a fresh element. | |
onViolation |
(details) => void |
— | Called once per failed source, fatal or not. | |
document |
Document |
globalThis.document |
Target document; useful for iframes and tests. | |
performance |
Performance |
globalThis.performance |
Injectable, used for failure diagnosis. | |
fetchImpl / cryptoImpl |
globals | Injectable, used by preflight verification. |
reason |
Meaning |
|---|---|
unverified |
No integrity value and no allowUnverified. Nothing was injected. |
integrity-format |
The integrity string was malformed, or used a non-SRI algorithm or a wrong-length digest. |
integrity-mismatch |
Preflight computed a different digest, or the element errored after a complete download. |
csp-blocked |
A securitypolicyviolation event named this URL. Certain. |
network |
The request did not complete: DNS, offline, connection reset, blocking extension. |
load-error |
The element errored and the signals were inconclusive. |
timeout |
Exceeded timeout; the element was removed. |
aborted |
Cancelled via AbortSignal. |
all-sources-failed |
Aggregate wrapper; see .errors and .describe(). |
missing-crossorigin |
A cross-origin source has integrity but crossOrigin: null, which can never pass. |
.github/workflows/ci.yml runs lint, tests and the build on Node 20, 22 and 24, and fails if the
committed dist/ no longer matches its sources — the vendored bundle is what most consumers copy,
so it must not drift.
$ npm test
ℹ tests 60
ℹ pass 60
ℹ fail 0Tests use Node's built-in node:test runner against a small DOM stub in test/dom-stub.js. jsdom
would work, but it neither fetches resources nor implements SRI, so the interesting case — an
element that errors after a full download — would have to be simulated by hand regardless. The
stub keeps the suite dependency-free and makes the DOM surface the library touches explicit:
createElement, setAttribute, appendChild, removeChild and addEventListener, and nothing
else. The preflight verifier is tested against Node's real crypto.subtle, cross-checked with
node:crypto.
Background on the problems this library is built around:
- Adding integrity to runtime-injected scripts — why dynamically created elements lose integrity, and the attribute ordering that keeps it.
- Implementing dynamic script loaders with integrity — the loader pattern this library formalises, including promise and caching semantics.
- SRI fallback with multiple CDN sources — designing fallback chains where each mirror carries its own digest.
- Handling SRI failures with onerror handlers — what the
errorevent does and does not tell you. - Debugging SRI hash mismatch errors — working out whether a mismatch is a rotated build, a proxy rewrite or a missing
crossorigin. - SHA-256 vs SHA-384 vs SHA-512 for SRI — why sha384 is the sensible default and how browsers choose between multiple values.
- Configuring Content Security Policy with SRI — how CSP and integrity interact, and why a CSP block is not a hash mismatch.
- Collecting CSP violation reports with the Reporting API — the reporting pipeline the
onViolationhook is designed to feed.
MIT. See LICENSE.
Maintained alongside the Subresource Integrity & supply chain hardening reference.