Using the Import Map integrity Key

Permalink to "Using the Import Map integrity Key"

Part of ES Modules & Import Map Integrity, this page covers the top-level integrity section of an import map: the JSON shape, how it composes with imports and scopes, what browsers that do not implement it do instead, and how to prove enforcement in DevTools.

Quick Reference

Permalink to "Quick Reference"
Item Value Notes
Where it lives Top-level key of the import map JSON Sibling of imports and scopes
Key type A URL string Resolved against the document base URL; bare names are rejected
Value type Integrity metadata Same grammar as the integrity attribute, e.g. sha384-…
Algorithms sha256, sha384, sha512 Prefer SHA-384; multiple space-separated digests allowed
Covers Entry module, static imports, dynamic import() Any module fetch whose URL matches a key
Precedence Element integrity attribute wins The map fills in where no attribute exists
Delivery Inline <script type="importmap"> only External import map files are not supported
CSP Needs a nonce or a 'sha256-…' source It is an inline script like any other
Ordering Before the first module resolution Emit it in <head> ahead of every module tag
Unsupported browsers Section ignored, map still applied Modules load unverified — a fail-open default

The mental model

Permalink to "The mental model"

An import map has always been a resolution table: it rewrites the specifier text a module asks for into a URL the browser can fetch. The integrity section adds a second, independent table to the same JSON document — a verification table keyed by the URLs that resolution produces. Nothing joins the two tables except the URL string itself. That separation is the single most important thing to internalise, because it explains almost every mistake people make with the feature. You do not write "lodash-es": "sha384-…"; you write the fully resolved URL that "lodash-es" maps to, and you write it exactly as the browser will construct it.

The consequence is that the verification table also covers URLs that never appear in imports at all. A relative import inside a dependency — import "./chunk-4f2a.js" from a module served off a CDN — resolves through ordinary URL resolution rather than through the map, but the resulting URL can still be listed in the integrity section and it will still be checked. Import maps are not required for the integrity section to do useful work on a page; the map is simply the vehicle that delivers the URL-to-digest table to the browser.

The table is an allow-list of known digests, not a requirement that every module carry one. A module fetched from a URL that has no entry loads exactly as it would have without the map. That is deliberate — it is what makes the feature deployable incrementally — but it means an attacker who can add a new script URL to your page is unaffected by the integrity section. Requiring integrity for scripts is a job for policy, which is covered separately in Configuring Content Security Policy with SRI.

Anatomy of an import map with an integrity section The imports section maps bare specifiers to absolute URLs, and the separate integrity section maps those same absolute URLs to SRI digests; the only thing joining the two tables is the URL string, and browsers without support ignore the integrity key while still honouring imports and scopes. two tables in one JSON block, joined only by the URL string "imports" — resolution "lodash-es" to https://cdn.example/lodash.js specifier text in, URL out "integrity" — checking https://cdn.example/lodash.js to "sha384-+W0dS3Ypz…" URL in, digest out same URL A browser without support parses "imports" and "scopes" as usual and skips the unknown "integrity" key: resolution yes, checking no

Canonical example: a complete map with both sections

Permalink to "Canonical example: a complete map with both sections"

The block below is a full document head. The import map carries all three sections, the inline map is allowed by a per-request nonce, and the entry module is additionally preloaded. Digests are illustrative — generate real ones for your own files before shipping.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Checkout</title>

  <script type="importmap" nonce="r4nd0mPerRequestValue">
  {
    "imports": {
      "lodash-es": "https://cdn.jsdelivr.net/npm/[email protected]/lodash.js",
      "app/": "/static/app/"
    },
    "scopes": {
      "/static/app/legacy/": {
        "lodash-es": "/static/vendor/lodash-4.17.21.js"
      }
    },
    "integrity": {
      "https://cdn.jsdelivr.net/npm/[email protected]/lodash.js": "sha384-+W0dS3YpzbsMDp+Chfci3NQWZGBEUBSo9WUfjtxDUh9YlXNsRXCfehx/aQgDyVPw",
      "/static/vendor/lodash-4.17.21.js": "sha384-wLdBA5jE9CBAjEDa1E7npl/guxlhLagxqsNim6Hh0YjeTiZqiTVIDislIA1xPIRa",
      "/static/app/main.js": "sha384-km62dHbI7ytLMmbWMnh8uqikpKJSDRejTWsE6vSBRr6AEK+u94i8T4mvH9JdU/An",
      "/static/app/checkout.js": "sha384-ICUnXL+NOJEEHYQIJMcy+nAYY5erB1kp6RQh+VOAvp7vJRG2KbZcRyURSUniCkvX"
    }
  }
  </script>

  <link rel="modulepreload"
        href="/static/app/main.js"
        integrity="sha384-km62dHbI7ytLMmbWMnh8uqikpKJSDRejTWsE6vSBRr6AEK+u94i8T4mvH9JdU/An"
        crossorigin="anonymous">
</head>
<body>
  <script type="module" src="/static/app/main.js" crossorigin="anonymous"></script>
</body>
</html>

Read the three sections in the order the browser uses them. imports turns lodash-es into a jsDelivr URL and turns the app/ prefix into /static/app/. scopes overrides that first table for modules whose own URL sits under /static/app/legacy/, pointing them at a self-hosted copy instead. integrity then supplies a digest for four concrete URLs, two of which are the two different lodash builds the previous two sections can produce. Because scopes can steer the same specifier to different files, every reachable target needs its own entry — this is the most common source of gaps in a hand-written map.

/static/app/checkout.js never appears in imports. It is there because main.js contains await import('app/checkout.js') behind a user interaction, and the whole point of the integrity section is that a lazily fetched module gets the same treatment as an eagerly fetched one. The same reasoning applies to build-emitted chunk files, which is the subject of SRI for Lazy-Loaded Chunks.

Two mechanical requirements come with this block. First, <script type="importmap"> is an inline script, so a policy of script-src 'nonce-r4nd0mPerRequestValue' https://cdn.jsdelivr.net is what makes it run at all; the mechanics of minting that value per response are in Generating Per-Request CSP Nonces. Second, the map must be registered before any module resolution begins, which is why it precedes both the modulepreload link and the module script.

Module fetch and digest comparison The document resolves a specifier through the imports map, issues a CORS request to the CDN origin, receives the response with an Access-Control-Allow-Origin header, hashes the body and compares it with the entry from the integrity section, then either evaluates the module or blocks it and throws a network error. document module graph CDN origin GET resolved URL, always in CORS mode 200 with Access-Control-Allow-Origin hash body, compare with the integrity entry digest matches, module evaluates and its own imports continue digest differs, module blocked and the import rejects

Variants

Permalink to "Variants"

Generate the map at build time

Permalink to "Generate the map at build time"

Hand-maintaining digests is untenable the moment a bundler renames files. Emit the map from the same build step that writes the assets, reading each file off disk:

// scripts/build-import-map.mjs
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';

const digest = (file) =>
  'sha384-' + createHash('sha384').update(readFileSync(file)).digest('base64');

const integrity = {};
for (const name of readdirSync('dist/static/app')) {
  if (name.endsWith('.js')) {
    integrity[`/static/app/${name}`] = digest(`dist/static/app/${name}`);
  }
}

writeFileSync(
  'dist/import-map.json',
  JSON.stringify({ imports: { 'app/': '/static/app/' }, integrity }, null, 2)
);

The template then inlines dist/import-map.json into the response body and stamps the nonce. Keep the digest command identical to the one your CI uses so the two never disagree; the equivalent shell one-liners are in Generating SRI Hashes with OpenSSL and shasum.

Permalink to "Put the hash on <link rel="modulepreload"> instead"

A modulepreload link accepts an integrity attribute directly, and for a small page that can be enough:

<link rel="modulepreload"
      href="https://cdn.jsdelivr.net/npm/[email protected]/lodash.js"
      integrity="sha384-+W0dS3YpzbsMDp+Chfci3NQWZGBEUBSo9WUfjtxDUh9YlXNsRXCfehx/aQgDyVPw"
      crossorigin="anonymous">

The difference is coverage, not strength. A preload link verifies the one response it fetches, so a module the page never preloads — anything reached by a dynamic import() after a user action, or a chunk name that only exists in one build — is fetched with no check at all. The integrity section attaches metadata to the URL rather than to a tag, so it applies whenever that URL is requested during module loading. The two also interact: if both are present and disagree, the preloaded response cannot satisfy the later import and the browser refetches or fails outright, so keep them generated from one source.

Where each mechanism applies A four-row matrix comparing the import map integrity section, the integrity attribute on a modulepreload link, and the integrity attribute on a script element, across static imports, dynamic imports, markup cost, and breadth of browser support. what is covered import map integrity section modulepreload integrity attribute script element integrity attribute static imports checked only if preloaded not covered dynamic import() checked rarely covered not covered markup cost one JSON block one link per URL one tag each breadth of support newest engines widely shipped universal

Keep a self-hosted target for the same specifier

Permalink to "Keep a self-hosted target for the same specifier"

Because scopes can point one specifier at two different files, the integrity section is a natural place to register both a CDN build and a local build of the same library. Adding the local URL costs one line and makes a switch to self-hosting a configuration change rather than a code change. Deciding when to make that switch, and what it costs, is covered in Serving Local Fallback Bundles.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • A blocked import map fails as a resolution error, not a security error. If your policy lacks a nonce or hash for the inline map, the map never runs and the browser reports Uncaught TypeError: Failed to resolve module specifier "lodash-es" — which looks like a bundler problem and sends people to the wrong file. Check the console for a CSP violation on an inline script before touching the map’s contents.

  • Omitting crossorigin on a modulepreload link wastes the preload. Module scripts are always fetched in CORS mode with same-origin credentials, so a preload without crossorigin="anonymous" can end up in a different cache partition than the import that follows it, and the file is downloaded twice — once verified, once not reused. Write the attribute on every preload that carries integrity. The credentials-mode rules behind this are in How CORS and crossorigin Affect SRI.

  • The URL must match the fetch exactly. A query string, a trailing ?v=3, an extra /./ segment, or an origin that redirects to another host will all produce a fetch URL that does not equal your key, and the module then loads unchecked with no error anywhere. Copy keys from the Network panel’s request URL column rather than from your source.

  • Late maps are ignored. Injecting <script type="importmap"> after the first module has begun resolving produces a console message on the order of an import map is added after module script load was triggered, and the map does nothing. Server-render the map; do not build it from client-side JavaScript.

  • HTMLScriptElement.supports('importmap') does not tell you about the integrity section. It returns true on every browser that understands import maps at all, including ones that ignore the integrity key. There is no dedicated feature-detection API for the section, so treat it as defence in depth rather than as a control you can prove is active on an arbitrary visitor’s browser.

Verification Steps

Permalink to "Verification Steps"

1. Confirm the JSON parses and the digests are right

Permalink to "1. Confirm the JSON parses and the digests are right"
node -e 'const m=require("./dist/import-map.json");console.log(Object.keys(m.integrity).length,"entries")'
openssl dgst -sha384 -binary dist/static/app/main.js | openssl base64 -A

The printed base64 must equal the value stored under /static/app/main.js, minus the sha384- prefix. A mismatch here means the map was generated from a different build than the one you are about to deploy.

2. Confirm the map is applied, not blocked

Permalink to "2. Confirm the map is applied, not blocked"

Load the page and run this in the DevTools console:

JSON.parse(document.querySelector('script[type="importmap"]').textContent).integrity

An object printing your URL-to-digest pairs proves the element reached the DOM with its content intact. If the console also shows a Refused to execute inline script violation, the CSP nonce is missing or stale and the map was never registered — fix that before reading anything else.

3. Prove enforcement with a deliberately wrong digest

Permalink to "3. Prove enforcement with a deliberately wrong digest"

On a staging copy, change one character in one digest and reload with the Network panel open.

Failed to find a valid digest in the 'integrity' attribute for resource
'https://cdn.jsdelivr.net/npm/[email protected]/lodash.js' with computed
SHA-384 integrity '+W0dS3Ypz...'. The resource has been blocked.

The request appears in the Network panel with the response received and then the module fails: a static import kills the graph, and a dynamic one rejects with TypeError: Failed to fetch dynamically imported module. Seeing that pair is the only reliable evidence the section is being enforced in the browser you are testing. If the module loads normally instead, this browser is ignoring the integrity key — the degradation path. Interpreting the failure text itself is covered in Debugging SRI Hash Mismatch Errors.

4. Confirm coverage of the lazy path

Permalink to "4. Confirm coverage of the lazy path"

Restore the correct digest, then trigger the interaction that runs import('app/checkout.js') and confirm the chunk request appears in the Network panel after the click, not during initial load. Corrupt that entry too and repeat step 3 — this is the case a preload-based approach silently misses.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Which browsers support the import map integrity key?

Chromium-based browsers shipped it first, in Chrome and Edge 127. Support in other engines has arrived later and unevenly, so treat the current MDN and caniuse entries as the authority rather than any date written in a guide. Import maps themselves are supported far more broadly than the integrity section is, which is exactly why the degradation behaviour matters.

What happens in a browser that does not support the integrity section?

The map still works. Import map parsing ignores unrecognised top-level keys and reports a console warning, so imports and scopes continue to drive module resolution and the modules load normally without being hash-checked. That is a fail-open outcome, which means the integrity section hardens a page but cannot be the only control you rely on.

Can I use a bare specifier as a key in the integrity section?

No. Keys in the integrity section are parsed as URLs, resolved against the document base URL, and matched against the URL a module is actually fetched from. A bare name such as lodash-es is not a valid URL, so the entry is discarded with a console warning. Use the same absolute or root-relative URL that appears on the right-hand side of your imports map.

Can a page have more than one import map?

The portable assumption is one map, registered before any module resolution happens. Newer Chromium versions accept several maps and merge them in document order, but that behaviour is not available everywhere yet. If you build a page from independent fragments, merge their maps server-side into a single block rather than emitting one per fragment.

Does an integrity attribute on the script tag override the import map entry?

Yes. When a script or link element carries its own integrity attribute, that metadata is used for that element’s fetch and the map is not consulted for it. The integrity section supplies metadata for fetches that have no attribute of their own, which is every static and dynamic import inside the module graph.

Permalink to "Related"

Related Articles

SRI for ES Module Imports
ES Modules & Import Map Integrity Asset Hashing & Dynamic Script…