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.


Runtime Script Injection Order A created script element has integrity and crossOrigin set, then load and error handlers, then src, before it is appended to the document which triggers the CORS-eligible fetch and SHA-384 verification resulting in onload or onerror. createElement 'script' set integrity + crossOrigin, src appendChild fetch + verify onload ✓ digest matched onerror ✗ SRI or network

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 integrity after append does nothing. The value is read when the fetch starts, which is at append time. Assign el.integrity (and el.crossOrigin) before appendChild; a later assignment leaves the script fetched and executed without verification.
  • Omitting crossOrigin blocks the script even with a correct hash. A cross-origin dynamic fetch without el.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 returns Access-Control-Allow-Origin.
  • onerror cannot 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-src refuses any injected script lacking the current nonce, even one with a valid integrity. Set el.nonce from the per-request value.
  • Never build integrity from 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 the src and the integrity gains 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.write bypasses this path entirely. Legacy tag-injection snippets that call document.write('<script src=…>') cannot attach an integrity property reliably and are ignored after the document finishes parsing. Migrate those to the createElement pattern 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.


Permalink to "Related"

Related Articles

Implementing Dynamic Script Loaders with Integrity
Dynamic Script Loading Patterns Asset Hashing & Dynamic Script…