Adding Integrity to Runtime-Injected Scripts
Permalink to "Adding Integrity to Runtime-Injected Scripts"Part of Dynamic Script Loading Patterns, this page shows how a script created with document.createElement('script') gets a verified integrity — the integrity and crossOrigin properties must both be set before the element is appended, or the check never runs.
Quick Reference
Permalink to "Quick Reference"| Property | Value / rule |
|---|---|
| Element | document.createElement('script') |
| Integrity property | el.integrity = 'sha384-…' |
| CORS property | el.crossOrigin = 'anonymous' |
| Ordering | Set integrity + crossOrigin before append |
| Fetch trigger | Appending the element to the document |
| Failure signal | el.onerror (SRI and network share it) |
| Strict CSP | Also set el.nonce to the per-request value |
Set every security-relevant property first, wire onload/onerror, then set src and append.
Why Property Order Decides Whether SRI Runs
Permalink to "Why Property Order Decides Whether SRI Runs"A statically parsed <script integrity="…" crossorigin="anonymous"> gets its attributes before the browser starts fetching, so verification is automatic. A script built in JavaScript is different: the resource fetch for a dynamically created script element begins when the element is inserted into the document, and the browser reads integrity and crossOrigin from the element at that moment. If you append first and set el.integrity afterward, the fetch has already started without an integrity value and the bytes execute unverified. The rule is therefore strict — assign el.integrity and el.crossOrigin before the append call, exactly as the static-tag hardening in CDN Trust Mapping & Routing demands for parsed markup. Because injected loaders are the exact path a require-sri-for script policy is meant to catch, missing either property turns a defense-in-depth control into a silent bypass.
The reflected DOM properties map one-to-one onto the HTML attributes: el.integrity sets integrity, and el.crossOrigin sets crossorigin (note the camelCase property versus the lowercase attribute). Assigning el.crossOrigin = 'anonymous' is equivalent to el.setAttribute('crossorigin', 'anonymous'); either works as long as it precedes the append. The integrity value can list several space-separated tokens — for example 'sha256-… sha384-…' — and the browser verifies against the strongest algorithm it supports, giving injected loaders the same rolling-upgrade path that static tags enjoy. What it cannot do is recover from an omitted crossOrigin: once an opaque response comes back, there are no bytes to hash and no token, however strong, will help.
Canonical Implementation
Permalink to "Canonical Implementation"A Promise-wrapped loader that sets integrity and crossOrigin before appending, and rejects on failure:
function loadScript({ src, integrity, crossOrigin = 'anonymous' }) {
return new Promise((resolve, reject) => {
const el = document.createElement('script');
// Security-relevant properties FIRST — read when the fetch begins.
el.integrity = integrity; // 'sha384-…'
el.crossOrigin = crossOrigin; // makes the fetch CORS-eligible
el.onload = () => resolve(el);
el.onerror = () => {
el.remove();
reject(new Error(`Script blocked (SRI mismatch or network error): ${src}`));
};
el.src = src; // set last
document.head.appendChild(el); // append triggers the fetch + integrity check
});
}
// Usage
await loadScript({
src: 'https://cdn.example.com/widget.min.js',
integrity: 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC',
});
Variant Examples
Permalink to "Variant Examples"Minimal imperative form
Permalink to "Minimal imperative form"Without a Promise wrapper, the ordering rule is identical — every property is set before appendChild:
const el = document.createElement('script');
el.integrity = 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC';
el.crossOrigin = 'anonymous';
el.onerror = () => console.error('SRI or network failure for widget.min.js');
el.src = 'https://cdn.example.com/widget.min.js';
document.head.appendChild(el);
CSP nonce for strict policies
Permalink to "CSP nonce for strict policies"Under a nonce-based Content Security Policy, the injected script also needs the current request’s nonce or CSP blocks it regardless of a valid integrity. Read the nonce your server emitted and assign it before appending:
function loadScriptWithNonce({ src, integrity, nonce }) {
return new Promise((resolve, reject) => {
const el = document.createElement('script');
el.integrity = integrity;
el.crossOrigin = 'anonymous';
el.nonce = nonce; // matches the per-request CSP nonce
el.onload = () => resolve(el);
el.onerror = () => reject(new Error(`Blocked: ${src}`));
el.src = src;
document.head.appendChild(el);
});
}
Sourcing that value per request is covered in Generating Per-Request CSP Nonces.
ES module script
Permalink to "ES module script"The same ordering applies to a module script — set type and the security properties before append:
const el = document.createElement('script');
el.type = 'module';
el.integrity = 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC';
el.crossOrigin = 'anonymous';
el.src = 'https://cdn.example.com/module.mjs';
document.head.appendChild(el);
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"- Setting
integrityafter append does nothing. The value is read when the fetch starts, which is at append time. Assignel.integrity(andel.crossOrigin) beforeappendChild; a later assignment leaves the script fetched and executed without verification. - Omitting
crossOriginblocks the script even with a correct hash. A cross-origin dynamic fetch withoutel.crossOrigin = 'anonymous'is no-CORS and yields an opaque response the browser cannot read. SRI cannot hash an opaque body, so the resource is blocked before the digest is compared — the classic silent failure, and the most common bug in injected loaders. Ensure the origin also returnsAccess-Control-Allow-Origin. onerrorcannot distinguish SRI from network failure. Both fire the same bare error event. To diagnose, read the DevTools console for the specific SRI blocking message or check the Network tab; do not branch program logic on a presumed cause.- Strict CSP needs a nonce, not just integrity. A nonce-based
script-srcrefuses any injected script lacking the current nonce, even one with a validintegrity. Setel.noncefrom the per-request value. - Never build
integrityfrom an unvalidated input. If the hash token comes from configuration or an API, treat it as trusted data from your build, not from user input — an attacker who controls both thesrcand theintegritygains nothing to verify against. - A rejected loader must not silently retry to a bare
src. A tempting fallback is to re-inject the script without an integrity token when the first attempt errors. That converts a blocked tamper attempt into a successful one. If you need resilience, retry against a second origin serving byte-identical content under the same token, never against an unverified URL. document.writebypasses this path entirely. Legacy tag-injection snippets that calldocument.write('<script src=…>')cannot attach anintegrityproperty reliably and are ignored after the document finishes parsing. Migrate those to thecreateElementpattern shown here so the integrity check actually runs.
Verification Steps
Permalink to "Verification Steps"1. Confirm properties are set before append
Permalink to "1. Confirm properties are set before append"Search the loader for the ordering. integrity and crossOrigin assignments must precede the appendChild call:
grep -nE "\.integrity|\.crossOrigin|appendChild" src/loadScript.js
Expected — the integrity and crossOrigin lines appear above the appendChild line.
2. Force a failure and observe the console
Permalink to "2. Force a failure and observe the console"Corrupt one character of the token and load the page with DevTools open. The browser logs:
Failed to find a valid digest in the 'integrity' attribute for resource
'https://cdn.example.com/widget.min.js' with computed SHA-384 integrity
'sha384-…'. The resource has been blocked.
Restore the token and confirm the message disappears and onload fires.
3. Assert rejection in a test
Permalink to "3. Assert rejection in a test"await expect(
loadScript({ src: cdnUrl, integrity: 'sha384-deadbeef' })
).rejects.toThrow(/blocked/i);
A passing test proves the loader surfaces integrity failures instead of swallowing them, which is the CI gate that keeps an unverified injection path from shipping.
Related
Permalink to "Related"- Dynamic Script Loading Patterns — parent workflow for injecting third-party and deferred scripts under integrity control
- Implementing Dynamic Script Loaders with Integrity — a fuller loader with retries, timeouts, and manifest-driven hashes
- Handling SRI Failures with onerror Handlers — graceful fallback once an injected script is blocked