
How to Secure Your Dynamic Module Imports Without Abandoning Subresource Integrity
Dynamic imports are a massive performance win for modern apps, but they often silently bypass your security policy—here is how to apply SRI to lazy-loaded code.
How to Secure Your Dynamic Module Imports Without Abandoning Subresource Integrity
I spent an entire afternoon a few months ago staring at a "Strict CSP" violation that made absolutely no sense. I had Subresource Integrity (SRI) hashes on every script tag. My index.html looked like Fort Knox. But the moment I clicked a button that triggered a dynamic import(), the security wall crumbled. It turns out, while we’ve all been told that code-splitting is the holy grail of performance, nobody really mentions that dynamic imports essentially bypass the standard SRI protection we've relied on for years.
If you’re using import('./heavy-module.js') to keep your initial bundle size small, you’re likely shipping code that has no integrity verification. If a CDN gets compromised or a build artifact is swapped, your users are executing whatever the attacker wants.
Here is how we fix that without going back to 2014-era giant bundles.
The Problem: import() is Hash-Blind
Standard script tags make SRI easy:
<script
src="https://cdn.example.com/app.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNqlGYpJ1kSgpX7L"
crossorigin="anonymous">
</script>The browser sees the hash, checks the file, and if one bit is off, it refuses to run it. Perfect.
But dynamic imports are a JavaScript language feature, not an HTML element. The syntax const module = await import('./module.js') has no parameter for an integrity hash. The browser just fetches the file and hopes for the best.
Strategy 1: The "Module Preload" Workaround
The most "platform-native" way to solve this right now isn't inside your JS files, but in your <head>.
Browsers recently introduced rel="modulepreload". It’s like rel="preload", but specifically for ES modules. Crucially, it supports the integrity attribute.
<head>
<!-- Preload the dynamic chunk with a hash -->
<link rel="modulepreload"
href="/js/chunks/dashboard.js"
integrity="sha384-Lc7R7F..."
crossorigin="anonymous">
</head>When your code later calls import('./js/chunks/dashboard.js'), the browser realizes it already has that file in its module map (verified by the hash). It uses the secured, cached version instead of fetching a new, unverified one.
The Catch: You have to know the hashes at build time and inject them into your HTML. If you have 500 dynamic chunks, your HTML head is going to look like a literal nightmare. This brings us to automation.
Strategy 2: Automating with Build Tools
Let's be real: nobody is manually calculating SHA-384 hashes and pasting them into link tags. You need your bundler to do the heavy lifting.
If you're using Webpack
There is a fantastic plugin called webpack-subresource-integrity. It handles the injection for you. Once configured, it automatically adds integrity hashes to the chunks Webload loads.
// webpack.config.js
const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity');
module.exports = {
output: {
crossOriginLoading: 'anonymous',
},
plugins: [
new SubresourceIntegrityPlugin({
hashFuncNames: ['sha384'],
enabled: process.env.NODE_ENV === 'production',
}),
],
};If you're using Vite
Vite actually handles a lot of this via its renderBuiltCode hooks, but for a truly robust setup, you might want to look at vite-plugin-sri. However, Vite’s default behavior for "modulepreload" polyfills often gets you halfway there by generating the preload tags automatically.
Strategy 3: The Manual "Fetch then Blob" (The Nuclear Option)
If you are in a weird environment where you can't use modulepreload (like some older enterprise browsers or specific Electron setups), you can technically "proxy" your imports.
I don’t recommend this unless you’re desperate, but it’s a good mental exercise in how the plumbing works:
async function secureImport(url, integrity) {
const response = await fetch(url, {
integrity: integrity,
mode: 'cors'
});
const scriptText = await response.text();
const blob = new Blob([scriptText], { type: 'application/javascript' });
const objectURL = URL.createObjectURL(blob);
return import(objectURL);
}
// Usage
const module = await secureImport('/js/module.js', 'sha384-XYZ...');Why this is usually a bad idea: You lose the ability to resolve relative paths inside that module, and you're essentially doubling the memory usage for that script. It’s a hack, but it proves that fetch is currently better at security than import().
The CSP Safety Net
SRI is great, but it’s only one half of the "don't execute malicious crap" sandwich. You should back this up with a strict Content Security Policy (CSP).
If you can’t get SRI hashes onto every single dynamic chunk, you should at least restrict *where* those chunks can come from using script-src-elem.
Content-Security-Policy: script-src 'self'; script-src-elem 'self' https://trusted-cdn.com;By setting script-src 'self', you prevent inline scripts and unauthorized domains from running code, even if they manage to trick your dynamic import logic.
Summary of the "Gotchas"
1. Cross-Origin: If your chunks are on a different domain (like a CDN), you must have CORS headers configured properly. If the server doesn't send Access-Control-Allow-Origin, the integrity check will fail and the script won't load.
2. Versioning: Every time you change one line of code in a dynamic module, its hash changes. Your index.html (containing the modulepreload tags) must stay in sync with your JS chunks. This is why automated build steps are non-negotiable.
3. Browser Support: While import() is universal in modern browsers, modulepreload support is still catching up in some niche areas. Always test your fallback behavior.
Securing dynamic imports feels like a chore because the spec writers didn't give us a import(url, { integrity: '...' }) syntax (yet). But by using modulepreload and leveraging your build tools, you can keep that sweet, sweet performance without leaving the door unlocked for supply chain attackers.


