Lorem, ipsum dolor sit amet consectetur adipisicing elit. Qui, itaque voluptate ipsa non enim amet ducimus voluptatibus deserunt nam esse!
When Subresource Integrity Fails: Dynamic Scripts and Imported Modules

When Subresource Integrity Fails: Dynamic Scripts and Imported Modules

pr0h0
subresource-integrityjavascriptdynamic-scriptses-modules
AI Usage (88%)

What Subresource Integrity Actually Covers

Subresource Integrity, or SRI, is easy to trust too much. It tells the browser not to load a resource unless the bytes match the expected hash, which is useful for static assets from a CDN or another third-party host.

The limit is scope. SRI works when the browser already knows the exact URL and the exact bytes it should see. It is not a blanket approval for every script your app might load later. If your code builds a script URL at runtime or loads a module graph through JavaScript, the browser can only verify the thing it was asked to verify.

That distinction shows up fast in real apps. Teams add analytics, feature flags, A/B tests, widgets, and microfrontend loaders. The code can still look tidy while the trust boundary has quietly moved.

Where Dynamic Scripts Break the Model

createElement('script') and late binding

A static tag is straightforward:

<script
  src="https://cdn.example.com/app.js"
  integrity="sha384-..."
  crossorigin="anonymous"
></script>

The browser fetches that file, hashes it, and compares the result with the expected digest.

Runtime injection is different:

const script = document.createElement("script");
script.src = getScriptUrlFromConfig();
document.head.appendChild(script);

At that point, the browser is following whatever URL your code produced. If the URL itself is untrusted, SRI does not help. If the URL is trusted but the content can change behind the scenes, you are looking at a deployment control problem, not just a browser control problem.

import() and module graphs

Dynamic import adds another wrinkle:

const mod = await import(`/widgets/${name}.js`);
mod.init();

import() is not the same thing as a static <script> tag with SRI. The module loader fetches the requested module, then resolves its dependencies. In practice, you are managing a graph of files, not one asset.

If the graph is built from runtime data, the browser is trusting your path construction. If one module imports another remote module, integrity checking becomes a packaging problem: every edge in the graph matters. A hash on the entry module does not magically pin every later fetch unless your deployment model enforces that.

⚠️

SRI is not a safe module-resolution layer. If an attacker can influence the URL passed to import(), the browser will fetch exactly what it was told to fetch.

Reproducing the Failure in a Small App

Static script tags versus runtime injection

I usually test this with the smallest possible setup: one static script, one dynamic script, and one dynamic import.

// static.html
// <script src="/vendor.js" integrity="sha384-..." crossorigin="anonymous"></script>

// runtime.js
const s = document.createElement("script");
s.src = window.assetBase + "/vendor.js";
document.head.appendChild(s);

The static version is pinned to a known resource. The runtime version depends on window.assetBase. If that value is wrong, attacker-controlled, or rewritten during deployment, SRI is not what fails first. The URL selection is.

What the browser checks and what it does not

The browser checks:

  • the fetched bytes against the declared integrity hash
  • CORS conditions required for cross-origin SRI use
  • whether the load should be blocked if the hash does not match

The browser does not check:

  • whether your runtime code chose the right URL
  • whether the module graph is safe
  • whether a config file or JSON manifest was tampered with before the script load
  • whether a later dynamic import lands on an unexpected path

That gap is where the bug lives. SRI protects the fetch result, not the logic that chose the fetch target.

Integrity Options for Modern Loading Patterns

Build-time pinning and hashed asset URLs

For modern frontends, the most reliable pattern is still build-time pinning. Emit content-hashed filenames and serve them as immutable assets:

// build output
/app.4f3c1a8e.js
/widgets/chart.91b7d2c1.js

Then treat the build manifest as the source of truth. If the hash changes, the URL changes. That gives you cache safety and a basic tamper signal.

This is stronger than hoping a mutable URL stays honest.

Server-side allowlists and deployment checks

If you must choose script targets at runtime, keep the decision on the server side and constrain it hard.

A practical pattern:

LayerControlWhat it prevents
BuildHashed filenamesSilent byte changes
ServerAllowlisted asset namesArbitrary script URLs
CI/CDManifest diff checkUnexpected artifact swaps
BrowserSRI on static third-party scriptsCDN or transit tampering

A good deployment check compares the generated asset manifest against the files that were actually published. If a script path appears that was not built, reject the release.

Trusted Types and strict script policies

Trusted Types does not replace SRI, but it helps stop accidental script injection paths from becoming URL factories. If your app uses innerHTML, insertAdjacentHTML, or similar sinks to assemble script tags, Trusted Types can cut off a large class of DOM-based mistakes.

Use a strict CSP with script-src that avoids broad wildcards as well. Even if an attacker finds a way to inject a script element, your policy should make that path difficult.

💪

Use SRI for fixed third-party dependencies, hashed asset URLs for your own builds, and Trusted Types to reduce accidental script injection paths. That combination covers more real failures than SRI alone.

Practical Defense Checklist

  • Use SRI only for assets with stable, known bytes.
  • Prefer content-hashed filenames for first-party scripts and modules.
  • Do not build script URLs from unsanitized runtime input.
  • Treat import() targets as security-sensitive input.
  • Keep an allowlist of valid assets on the server or in build output.
  • Verify the deployment manifest against what was actually published.
  • Lock down script-src in CSP.
  • Add Trusted Types if your app creates HTML or script nodes dynamically.
  • Review any loader that can change the module path after build time.

The main lesson is simple: SRI is a byte check, not a trust system. It works best when the URL is fixed and breaks down when JavaScript decides what to load next.

Conclusion

I still use SRI, but I use it with a narrower mental model now. It answers one question: did this fetched file match the expected hash? It does not answer: was the right file requested, were all later imports pinned, or did runtime code widen the attack surface?

If your app relies on dynamic loading, defend the loader, not just the asset. That means pinned build outputs, strict server-side controls, and a CSP that assumes script selection can go wrong.

Share this post

More posts

Comments