SRI for ES Module Imports

Permalink to "SRI for ES Module Imports"

Part of ES Modules & Import Map Integrity, this page is the copy-pasteable syntax reference for hashing an ES module entrypoint and extending integrity to every module it imports through an import map integrity section.

Quick Reference

Permalink to "Quick Reference"
Property Value
Entry verification <script type="module" integrity="sha384-…" crossorigin="anonymous">
Import verification Import map "integrity" object: { "<moduleURL>": "sha384-…" }
Algorithm identifier sha384 (default); sha256 / sha512 as explicit variants
Integrity key Resolved module URL, not the bare specifier
Covers static import Only via the import map integrity section
Covers dynamic import() Yes — keyed by the same resolved URL
Covers modulepreload Yes — set integrity on the <link> too
Browser support (field enforcement) Chromium 127+ (Chrome, Edge); Firefox / Safari not yet
Fetch mode Module scripts always CORS; crossorigin still required

Use SHA-384 for every hash unless a specific constraint forces another algorithm.

How It Fits Together

Permalink to "How It Fits Together"

A <script type="module"> with an integrity attribute verifies only the entry file. The static import statements inside it trigger further fetches that the entry hash never covers — so an unverified imported module is a real substitution risk. If an attacker who controls a CDN path can swap one imported utility module, the entry hash still validates and the tampered code runs, because the browser never had a hash to check the import against.

The import map integrity field, standardized through the WICG Import Maps proposal and integrated into the HTML Standard, closes that gap: it maps each resolved module URL to an integrity token, and the loader enforces that token on every fetch of that URL. The map is keyed by URL rather than by DOM element, which is why the same entry covers static import, dynamic import(), and modulepreload uniformly — they all resolve to a URL, and every URL in the map is enforced. One hashed entrypoint plus one integrity map covers the whole graph. The full enforcement mechanics live in Browser Enforcement & Security Boundaries.

Canonical Implementation

Permalink to "Canonical Implementation"

A hashed entrypoint and an import map whose integrity section maps every module URL to a sha384 token. Declare the import map before the module script.

<!-- 1. Import map: specifiers → URLs, plus an integrity section per URL -->
<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>

<!-- 2. Entry module: its own integrity attribute + crossorigin -->
<script
  type="module"
  src="https://cdn.example.com/app.4S2rX8.mjs"
  integrity="sha384-4S2rX8y0m0Vv3o9Q1n7pZ8kYh2Tf6bWc9Ld0eRa5uNm3xQ7iVpB1sZ8oKjHgFd"
  crossorigin="anonymous"></script>

Inside app.4S2rX8.mjs, import { render } from '@app/render' resolves to render.9Kd0eR.mjs, the loader finds that URL in the integrity map, and enforces the hash. Generate the tokens with the standard SRI tooling in How to Calculate SHA-256 vs SHA-384 for SRI.

Variant Examples

Permalink to "Variant Examples"

Native ESM (unbundled modules)

Permalink to "Native ESM (unbundled modules)"

With no bundler, every module is a separate file and a separate fetch, so each needs an entry in the integrity map. This is where the field matters most:

<script type="importmap">
{
  "integrity": {
    "/modules/main.mjs": "sha384-Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv12Wx34Yz56Ab78Cd90Ef",
    "/modules/store.mjs": "sha384-Zx98Wv76Ut54Sr32Qp10On98Ml76Kj54Ih32Gf10Ed98Cb76Az54Yx32Wv10Ut"
  }
}
</script>
<script type="module" src="/modules/main.mjs"
  integrity="sha384-Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv12Wx34Yz56Ab78Cd90Ef"
  crossorigin="anonymous"></script>

Same-origin module URLs (/modules/main.mjs) do not strictly require crossorigin, but keeping it explicit avoids surprises when a module later moves to a CDN.

Bundled output

Permalink to "Bundled output"

A bundler collapses the graph into a handful of content-hashed chunks, so a single integrity attribute per emitted file usually suffices and the import map may only need entries for lazily loaded chunks. Emit the tokens from your build — Generating SRI Hashes in Vite and Automating Hash Generation in Webpack 5 both produce manifests you transform into the integrity object:

<script type="module" src="/assets/index.4S2rX8.js"
  integrity="sha384-4S2rX8y0m0Vv3o9Q1n7pZ8kYh2Tf6bWc9Ld0eRa5uNm3xQ7iVpB1sZ8oKjHgFd"
  crossorigin="anonymous"></script>

Dynamic import()

Permalink to "Dynamic import()"

Integrity metadata is keyed by URL, so it applies to lazy chunks loaded at runtime with no extra syntax — as long as the resolved URL is in the map:

// The loader looks up '/chunks/editor.7pQ2.mjs' in the import map integrity
// section and enforces its hash before executing the dynamic import.
const { openEditor } = await import('@app/editor');
{
  "imports": { "@app/editor": "/chunks/editor.7pQ2.mjs" },
  "integrity": { "/chunks/editor.7pQ2.mjs": "sha384-7pQ2mVpB1sZ8oKjHgFd4S2rX8y0m0Vv3o9Q1n7pZ8kYh2Tf6bWc9Ld0eRa5uNm3x" }
}

For modules injected at runtime by constructing elements rather than using import(), see Implementing Dynamic Script Loaders with Integrity.

Preloaded modules

Permalink to "Preloaded modules"

Warming the module graph with <link rel="modulepreload"> fetches the module early. That fetch honors the import map integrity entry for its URL, and you can also set integrity on the link itself so the preload and the later import both enforce the same hash:

<link rel="modulepreload"
  href="https://cdn.example.com/render.9Kd0eR.mjs"
  integrity="sha384-9Kd0eRa5uNm3xQ7iVpB1sZ8oKjHgFd4S2rX8y0m0Vv3o9Q1n7pZ8kYh2Tf6bWc"
  crossorigin="anonymous">

Keep the preload token identical to the map entry — a divergence means the warmed copy and the imported copy disagree, and the module is refetched or blocked.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Static imports are not covered by the entry hash. The integrity attribute on the module script verifies only that one file. import './x.mjs' fetches x.mjs separately; if its URL is absent from the import map integrity section it executes unverified. List every module URL the graph reaches — this transitive-import gap is the whole reason the field exists.

  • Omitting crossorigin breaks cross-origin modules. Without crossorigin="anonymous" the browser cannot obtain a readable, CORS-eligible response for a cross-origin module, so it cannot compute the digest and blocks the module. The console shows a load failure, not a clean hash-mismatch message, which makes this the hardest ESM SRI bug to diagnose. Always pair integrity with crossorigin, and confirm the origin returns Access-Control-Allow-Origin.

  • The key must be the resolved URL. An integrity key of @app/render or ./render.mjs will not match a fetch of https://cdn.example.com/render.9Kd0eR.mjs. Copy the resolved URL from the Network panel verbatim; a trailing slash or query-string difference silently skips enforcement.

  • Firefox and Safari do not enforce the field yet. They support import maps but ignore the integrity key, so imported modules load unverified there. Keep a CI hash gate as the backstop and do not treat the field as universal enforcement.

  • Hash the built bytes, not the source. Minifiers and transpilers change the file. Compute every token after the build, against the exact bytes the server delivers, or every module mismatches.

  • One import map wins per document. The browser honors a single import map for resolution; a second <script type="importmap"> declared after modules start resolving is ignored, taking its integrity entries with it. Consolidate every specifier and every hash into one map placed before the first module script.

  • Bare-specifier keys silently do nothing. Only resolved URLs are enforced. It is tempting to write "@app/render": "sha384-…" in the integrity object, but the loader looks the fetch up by URL, so that entry never matches and the module loads unverified.

Verification Steps

Permalink to "Verification Steps"

1. Confirm the tags carry integrity and crossorigin

Permalink to "1. Confirm the tags carry integrity and crossorigin"
grep -E 'type="module"|integrity=|crossorigin=|"integrity"' dist/index.html

Every module <script> should show integrity and crossorigin, and the import map should contain an "integrity" object.

2. Prove enforcement reaches imported modules

Permalink to "2. Prove enforcement reaches imported modules"

Corrupt one character of an imported module’s hash in the integrity object, reload in a Chromium 127+ browser with DevTools open, and expect:

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 the block for an imported module — not just the entry — confirms the map is live. Restore the hash to clear it.

3. CI gate on hash drift

Permalink to "3. CI gate on hash drift"

Re-derive every module digest from the built output and fail if the committed import map is stale:

node scripts/build-import-map-integrity.mjs
git diff --exit-code dist/importmap-integrity.json

A non-zero exit blocks the deployment before a stale hash reaches a CDN edge.


Permalink to "Related"
ES Modules & Import Map Integrity Asset Hashing & Dynamic Script…