
When Subresource Integrity Fails: Dynamic Scripts and Imported Modules
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:
| Layer | Control | What it prevents |
|---|---|---|
| Build | Hashed filenames | Silent byte changes |
| Server | Allowlisted asset names | Arbitrary script URLs |
| CI/CD | Manifest diff check | Unexpected artifact swaps |
| Browser | SRI on static third-party scripts | CDN 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-srcin 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.


