ES Modules & Import Map Integrity
Permalink to "ES Modules & Import Map Integrity"This workflow is part of Asset Hashing & Dynamic Script Injection. Native ES modules introduce an integrity gap that catches most teams by surprise: putting an integrity attribute on a <script type="module"> tag verifies the entry file, but every module that file pulls in through a static import statement is fetched separately and is not covered by that hash. An attacker who can substitute one deeply imported utility module bypasses the verification entirely while the entry hash still validates.
The workflow on this page closes that gap. It shows how to hash a module entrypoint, why the entry hash stops at the module boundary, and how the newer import map integrity field extends cryptographic verification to every imported and dynamically imported module. It covers the spec basis, a five-step implementation, a configuration reference, troubleshooting, the security boundary, and how native ESM and bundlers differ.
Prerequisites
Permalink to "Prerequisites"Conceptual Foundation: Why the Entry Hash Stops at the Module Boundary
Permalink to "Conceptual Foundation: Why the Entry Hash Stops at the Module Boundary"When the browser encounters <script type="module" src="…" integrity="sha384-…">, it fetches the entry module, computes its digest, and blocks execution on mismatch — exactly as the W3C Subresource Integrity specification (§3.3) prescribes for any script element. The problem is what happens inside that module. A statement like import { render } from './render.mjs' triggers a fresh network fetch performed by the module loader, not by an element in the DOM. There is no integrity attribute attached to that fetch, so the loader has nothing to compare against and the bytes execute unverified.
The Import Maps proposal (WICG), now integrated into the HTML Standard, addresses this with an integrity field. An import map is a JSON document declared in <script type="importmap"> that remaps module specifiers to URLs. The integrity extension adds a top-level "integrity" object whose keys are module URLs and whose values are integrity metadata strings — the same sha384-<base64> token format used everywhere else in SRI. When the module loader resolves a specifier to a URL, it consults the integrity map; if the URL has an entry, the loader enforces that hash on the fetch, regardless of whether the fetch came from a static import, a dynamic import(), or a <link rel="modulepreload">.
This gives a clean two-part model:
- The
integrityattribute on the module script element verifies the entry module only. - The import map
integrityfield verifies every other module URL the loader fetches, keyed by resolved URL.
Together they extend a single trust decision — “these exact bytes, and nothing else” — across the whole module graph. The diagram below shows the gap and how the integrity field closes it.
Step 1 — Hash the Entry Module
Permalink to "Step 1 — Hash the Entry Module"Compute the SHA-384 digest of the exact bytes your server will deliver, after the build has finished. Hashing a pre-minified source file is the single most common cause of a mismatch — the browser hashes what it receives, not what you wrote.
# Hash the built entry module the same way you would any SRI asset
openssl dgst -sha384 -binary dist/app.mjs | openssl base64 -A
# → sha384-4S2rX8y0m0Vv3o9Q1n7pZ8k... (prefix with sha384- in the attribute)
Expected output is a single 64-character base64 string with no trailing newline (the -A flag suppresses wrapping). Prefix it with sha384- to form the integrity token. For a full walk-through of the CLI, Node.js, and Python variants, use How to Calculate SHA-256 vs SHA-384 for SRI.
Step 2 — Add the integrity Attribute to the Module Script Element
Permalink to "Step 2 — Add the integrity Attribute to the Module Script Element" Attach the token from Step 1 to the <script type="module"> element that boots your application. Module scripts are fetched in CORS mode, but the integrity check still requires the response to be CORS-eligible, so crossorigin="anonymous" is mandatory for any cross-origin module.
<!-- Correct: type=module + integrity + crossorigin all present -->
<script
type="module"
src="https://cdn.example.com/app.4S2rX8.mjs"
integrity="sha384-4S2rX8y0m0Vv3o9Q1n7pZ8kYh2Tf6bWc9Ld0eRa5uNm3xQ7iVpB1sZ8oKjHgFd"
crossorigin="anonymous"></script>
Expected behaviour: the browser fetches app.4S2rX8.mjs, verifies it, and executes it. This attribute verifies only app.4S2rX8.mjs. Any module it imports is still unverified at this point — that is what Step 3 fixes.
Step 3 — Declare Import Map Integrity for the Imported Modules
Permalink to "Step 3 — Declare Import Map Integrity for the Imported Modules"Add a <script type="importmap"> before the module script and give it an integrity object. Each key is a module URL (relative URLs resolve against the document base URL); each value is the SHA-384 token for that module. This is the mechanism that extends verification across the whole graph.
<script type="importmap">
{
"imports": {
"app": "https://cdn.example.com/app.4S2rX8.mjs",
"@app/render": "https://cdn.example.com/render.9Kd0eR.mjs",
"@app/utils": "https://cdn.example.com/utils.2xQ7iV.mjs"
},
"integrity": {
"https://cdn.example.com/app.4S2rX8.mjs": "sha384-4S2rX8y0m0Vv3o9Q1n7pZ8kYh2Tf6bWc9Ld0eRa5uNm3xQ7iVpB1sZ8oKjHgFd",
"https://cdn.example.com/render.9Kd0eR.mjs": "sha384-9Kd0eRa5uNm3xQ7iVpB1sZ8oKjHgFd4S2rX8y0m0Vv3o9Q1n7pZ8kYh2Tf6bWc",
"https://cdn.example.com/utils.2xQ7iV.mjs": "sha384-2xQ7iVpB1sZ8oKjHgFd4S2rX8y0m0Vv3o9Q1n7pZ8kYh2Tf6bWc9Ld0eRa5uNm"
}
}
</script>
Expected behaviour: when app.mjs runs import { render } from '@app/render', the loader resolves the specifier to render.9Kd0eR.mjs, finds that URL in the integrity map, and enforces the hash on the fetch. A tampered render.9Kd0eR.mjs is now blocked exactly like a tampered entry module would be. Keys not present in the map are fetched without enforcement, so every module URL your graph reaches must appear here.
Step 4 — Verify in the Browser
Permalink to "Step 4 — Verify in the Browser"Load the page in a Chromium 127+ browser with DevTools open. In the Network panel, every module request should show 200 and execute. Force a failure to prove the map is live: flip one character of a hash in the integrity object and reload.
Failed to find a valid digest in the 'integrity' attribute for resource
'https://cdn.example.com/render.9Kd0eR.mjs' with computed SHA-384 integrity
'sha384-9Kd0eRa5uNm3xQ7iVpB1sZ8oKjHgFd…'. The resource has been blocked.
Seeing this error for an imported module — not just the entry — confirms that import map integrity is enforcing across the graph. Restore the correct hash and the error disappears. If the error never appears even with a corrupted hash, the browser does not support the field; treat that as unenforced and rely on the CI gate in Step 5.
Step 5 — Gate the Build in CI
Permalink to "Step 5 — Gate the Build in CI"Because hashes drift the moment any module’s bytes change, the import map must be regenerated and re-verified on every build. Generate the map from the built output rather than maintaining it by hand, then fail the pipeline if the committed map is stale.
// scripts/build-import-map-integrity.mjs
// Run: node scripts/build-import-map-integrity.mjs
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const DIST = 'dist';
const BASE = 'https://cdn.example.com/';
const sri = (file) =>
'sha384-' + createHash('sha384').update(readFileSync(join(DIST, file))).digest('base64');
const modules = readdirSync(DIST).filter((f) => f.endsWith('.mjs'));
const integrity = Object.fromEntries(modules.map((f) => [BASE + f, sri(f)]));
writeFileSync('dist/importmap-integrity.json', JSON.stringify({ integrity }, null, 2));
console.log(`Wrote integrity for ${modules.length} modules`);
# .github/workflows/deploy.yml (excerpt)
- name: Regenerate import map integrity
run: node scripts/build-import-map-integrity.mjs
- name: Fail if the committed import map is stale
run: git diff --exit-code dist/importmap-integrity.json
Expected output: the script prints a module count and exits 0; the git diff --exit-code step passes only when the checked-in map already matches the freshly built hashes. Any drift returns a non-zero exit and blocks promotion before stale hashes reach a CDN edge. Wire this alongside your existing CDN Trust Mapping & Routing gates.
Configuration Reference
Permalink to "Configuration Reference"| Option | Where | Valid values | Notes |
|---|---|---|---|
type="module" |
<script> element |
module |
Required for the entry to participate in the module graph and honor the import map |
integrity (entry) |
<script type="module"> |
sha384-<base64> (preferred) |
Verifies the entry module’s bytes only; use sha256-/sha512- only as explicit variants |
crossorigin |
<script type="module"> |
anonymous, use-credentials |
anonymous for public modules; required for CORS-eligible responses |
"integrity" map |
<script type="importmap"> |
{ "<moduleURL>": "sha384-…" } |
Keys are resolved module URLs; values are integrity metadata; applies to static and dynamic fetches |
| Import map key form | integrity object | Absolute or base-relative URL | Must match the URL the loader resolves to, not the bare specifier |
| Import map count per document | document | Exactly one enforced map | A single import map governs the document; declare all integrity entries in it |
<link rel="modulepreload"> |
<head> |
integrity="sha384-…" |
Preloaded modules honor the same integrity metadata; keep it consistent with the map |
Integration with Adjacent Tooling
Permalink to "Integration with Adjacent Tooling"Import map integrity sits downstream of your hash generation step and upstream of the browser’s module loader. It connects to two adjacent workflows:
- SRI for ES Module Imports — the focused reference for the exact syntax: a hashed entrypoint plus an import map with an integrity section mapping specifiers to
sha384hashes, including native-ESM, bundled, and dynamicimport()variants. - Implementing Dynamic Script Loaders with Integrity — when a module is injected at runtime with
import()or a constructed script element, the loader must still supply integrity; that page covers the runtime-injection patterns that pair with this map.
For bundler-driven pipelines, the hashes that populate the import map come from the same source as your other tokens — Generating SRI Hashes in Vite and Automating Hash Generation in Webpack 5 both emit manifests you can transform into the integrity object. Enforcement itself is documented in Browser Enforcement & Security Boundaries.
Troubleshooting
Permalink to "Troubleshooting"Imported module executes even though its bytes changed
The module URL is missing from the import map integrity object, so the loader had nothing to enforce. The entry integrity attribute never covers imports. Add the URL — exactly as the loader resolves it — to the integrity map and rebuild.
Failed to find a valid digest in the 'integrity' attribute … The resource has been blocked for one module only
A single module’s hash is stale — its bytes changed but the map still holds the old digest. This is the normal symptom of skipping the CI regeneration in Step 5. Re-run the map generator against the built output and redeploy.
Import map integrity has no effect in Firefox or Safari
Those engines support import maps but had not shipped the integrity field at the time of writing, and unknown keys are ignored rather than erroring. The modules are fetched without enforcement. Keep the CI gate as your backstop and treat browser enforcement as available only on Chromium 127+.
Refused to load the module … request client is not a secure context or opaque-response block
A cross-origin module was fetched without a CORS-eligible response. Add crossorigin="anonymous" to the module script and confirm the origin returns Access-Control-Allow-Origin. Without a readable body the browser cannot compute the digest and blocks the module.
Import map key does not match and enforcement silently skips
The integrity key must be the resolved module URL, not the bare specifier (@app/render) and not a path that differs by trailing slash or query string. A key of ./render.mjs will not match a fetch of https://cdn.example.com/render.9Kd0eR.mjs. Log the resolved URL from the Network panel and copy it verbatim into the map.
Second import map ignored
A document honors one import map for resolution; declaring a second one after modules have begun resolving is ignored. Consolidate every specifier and every integrity entry into a single <script type="importmap"> placed before the first module script.
Security Boundary
Permalink to "Security Boundary"Import map integrity guarantees that every module URL the loader fetches matches a hash you computed at build time. It does not:
- Verify modules whose URLs you forgot to list — an unlisted URL is fetched with no enforcement. Coverage is only as complete as the map, which is why the CI gate must generate the map from the actual output.
- Protect against a compromised build — if a malicious module is baked in and hashed, its hash validates. Build-time provenance needs Provenance Verification Workflows.
- Guard against malicious source dependencies that entered the graph legitimately — SRI hashes the emitted module, not the npm package it came from. That requires Dependency Pinning Best Practices.
- Stop DOM-based injection after a module runs — once a verified module executes, integrity’s job is done; runtime injection is the domain of CSP and Trusted Types.
- Provide enforcement in engines without the field — on Firefox and Safari the imported modules load unverified today; do not treat the field as a universal control until support is broad.
Frequently asked questions
Permalink to "Frequently asked questions"Does the integrity attribute on a module script cover the modules it imports?
No. The integrity attribute on a <script type="module"> element verifies only the entry module’s own bytes. Static import statements inside that module trigger separate fetches performed by the module loader, which the entry hash does not cover. To verify those transitive modules you must declare an integrity section in an import map that maps each imported module URL to its hash.
Which browsers support the import map integrity field?
Import map integrity shipped in Chromium-based browsers (Chrome and Edge) from version 127. Firefox and Safari support import maps themselves but had not shipped the integrity field at the time of writing. Because unknown import map keys are ignored, the field degrades gracefully — pages still load in those engines, but the imported modules are not integrity-enforced there, so keep a CI gate as your backstop.
Do I still need crossorigin on a module script?
Yes, for any cross-origin module. Module scripts are always fetched in CORS mode, but the integrity check still requires a CORS-eligible response. Add crossorigin="anonymous" and ensure the origin returns an Access-Control-Allow-Origin header; otherwise the response is opaque, the browser cannot read the bytes, and the module is blocked.
Does import map integrity cover dynamic import() calls?
Yes. The integrity metadata in an import map is keyed by resolved module URL, so it applies to any fetch of that URL — whether the fetch originates from a static import statement, a dynamic import() expression, or a <link rel="modulepreload">. The loader looks up the URL in the integrity map on every module fetch.
Should I use native ES modules or a bundler for SRI?
A bundler collapses your module graph into a few content-hashed files, so a single integrity attribute per output file covers everything inside it and you may not need the import map field at all. Native ESM keeps modules separate, and each is a distinct fetch that needs its own hash — that is exactly where import map integrity earns its place.
Related
Permalink to "Related"- SRI for ES Module Imports — the copy-pasteable syntax reference: hashed entrypoint plus an import map integrity section, with native, bundled, and dynamic variants
- Implementing Dynamic Script Loaders with Integrity — applying integrity to modules and scripts injected at runtime
- Static Asset Hash Generation — the upstream step that produces the module hashes this map consumes
- Browser Enforcement & Security Boundaries — how the browser fetch pipeline validates each hash and handles opaque responses