Integrity for Workers & WebAssembly
Permalink to "Integrity for Workers & WebAssembly"This page belongs to Core SRI Fundamentals & Browser Security Boundaries and covers the part of the browser that the integrity attribute never reaches. Subresource Integrity is an attribute on markup, and markup only exists in a document. The moment execution moves into a worker scope or into the WebAssembly engine, there is no element left to hang the attribute on: new Worker(url) takes a URL and an options bag with no integrity member, importScripts() takes bare strings, navigator.serviceWorker.register() takes a scope and a caching hint, and WebAssembly.instantiateStreaming() takes a Response. Teams that have carefully hashed every <script> on the page routinely discover that the largest single blob of code they ship — a 900 kB worker bundle or a multi-megabyte .wasm runtime — is loaded with no cryptographic check at all.
The gap matters because these contexts are not weaker sandboxes; they are frequently more privileged in practice. A dedicated worker has full access to fetch, WebSocket, IndexedDB and crypto.subtle. A service worker sits in front of every network request the page makes and can rewrite responses for as long as it stays registered. A WebAssembly module has linear memory the host hands it and whatever import object you pass in. Substituted bytes in any of those places are at least as damaging as a substituted <script> — and considerably harder to notice, because none of them raise a console error when the content silently changes. This page defines exactly where declarative integrity stops, then builds the replacement: a build-time hash manifest, a verification helper on top of SubtleCrypto, and the CSP directives that make the whole arrangement enforceable rather than merely conventional.
Prerequisites
Permalink to "Prerequisites"Conceptual Foundation: where the attribute stops
Permalink to "Conceptual Foundation: where the attribute stops"The Subresource Integrity specification defines integrity metadata as a property of link and script elements, and the HTML Standard threads that metadata into the fetch it performs for those elements. Every other fetch in the platform is reached through an algorithm that either has no integrity parameter or explicitly sets it to the empty string. “Fetch a classic worker script” and “fetch a module worker script graph” both pass empty integrity metadata; the service worker update algorithm performs its own fetch with a Service-Worker: script request header and no integrity input; and WebAssembly.instantiateStreaming() receives a Response object that has already been through fetch, long after any integrity check would have run.
Two consequences follow, and they are frequently confused with each other. The first is that there is nowhere to declare a hash, which is a syntax problem. The second is that the fetch is not integrity-aware, which is a semantics problem: even if you could smuggle a hash in, nothing in the pipeline would compare it. That is why partial workarounds fail. A <link rel="preload" as="fetch" crossorigin="anonymous" integrity="sha384-..."> for a .wasm file really is validated by modern browsers, but a failed preload does not block the later fetch() of the same URL — the preload entry is simply discarded and the real request goes to the network unchecked. It is a performance hint that happens to validate, not a control you can enforce on.
The compensating control the platform does give you is the same-origin restriction. A classic or module worker’s top-level script must be same-origin with the creating document, and a service worker script must be same-origin with its registration. That removes the CDN from the threat model for those entry points and reduces the problem to one you can solve with your own build: if only your origin can supply worker code, then pinning the bytes your origin serves is sufficient. WebAssembly gets no such protection — fetch() will happily retrieve a cross-origin .wasm file with CORS headers — so it needs the full digest treatment.
Step 1 — Emit a build-time hash manifest
Permalink to "Step 1 — Emit a build-time hash manifest"Everything downstream depends on one artifact: a JSON file mapping asset paths to sha384- prefixed digests, generated by the same build that produced the assets and published alongside them. The manifest is the root of trust. It is delivered as part of the app shell, from the same origin, ideally under a <script> tag or fetch() that is itself covered by the page’s CSP; everything else is verified against it.
Generate it as a post-build step so it sees the final, minified, content-hashed output rather than intermediate chunks:
// scripts/build-integrity-manifest.mjs
// Emits dist/integrity-manifest.json: { "/assets/worker-a91f.js": "sha384-..." }
import { createHash } from 'node:crypto';
import { readFile, writeFile, readdir } from 'node:fs/promises';
import path from 'node:path';
const DIST = 'dist';
const HASHED = new Set(['.js', '.mjs', '.wasm']);
async function* walk(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else yield full;
}
}
const manifest = {};
for await (const file of walk(DIST)) {
if (!HASHED.has(path.extname(file))) continue;
const bytes = await readFile(file);
const digest = createHash('sha384').update(bytes).digest('base64');
const key = '/' + path.relative(DIST, file).split(path.sep).join('/');
manifest[key] = `sha384-${digest}`;
}
const out = path.join(DIST, 'integrity-manifest.json');
await writeFile(out, JSON.stringify(manifest, null, 2) + '\n');
console.log(`wrote ${Object.keys(manifest).length} entries to ${out}`);
Expected output:
wrote 14 entries to dist/integrity-manifest.json
Two details are load-bearing. The digest is computed over the raw file bytes, not over a transformed or re-encoded copy, and it is base64-encoded with the standard alphabet — the same encoding the integrity attribute uses, described in Base64 Encoding Rules for SRI Hashes. Reusing the exact SRI string format is deliberate: the same manifest can drive the integrity attributes your HTML template emits and the runtime checks described below, so there is one source of truth and one drift signal. Wire the same file into your release gate as described in Verifying Deployed Assets Against a Hash Manifest, and a byte change anywhere between build and edge becomes a failed pipeline rather than a silent substitution.
The manifest must never be generated by the server at request time. A manifest computed from whatever the server currently holds will faithfully certify a compromised file. It has to be produced in the build, ideally signed or at least attested, and treated as immutable for the lifetime of the release.
Step 2 — Verify bytes with SubtleCrypto before execution
Permalink to "Step 2 — Verify bytes with SubtleCrypto before execution"crypto.subtle.digest() is available in windows, dedicated workers and service workers, in any secure context. It takes an algorithm name and a buffer and returns a promise for the digest as an ArrayBuffer. There is no streaming variant, so verification always requires holding the complete resource in memory at least once — a real cost for a 30 MB .wasm file, and the main reason to keep verified assets as small as the build allows.
The following helper is the single choke point every later step calls. It loads the manifest once, resolves the asset’s path key, fetches the bytes, digests them, and either returns the buffer or throws.
// src/verify.js
const MANIFEST_URL = '/integrity-manifest.json';
const ALGORITHMS = { sha256: 'SHA-256', sha384: 'SHA-384', sha512: 'SHA-512' };
let manifestPromise;
function loadManifest() {
manifestPromise ??= fetch(MANIFEST_URL, { cache: 'no-cache', credentials: 'omit' })
.then((res) => {
if (!res.ok) throw new Error(`integrity manifest: HTTP ${res.status}`);
return res.json();
});
return manifestPromise;
}
export function toBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
export async function digestOf(bytes, algorithm = 'SHA-384') {
const prefix = algorithm.toLowerCase().replace('-', '');
return `${prefix}-${toBase64(await crypto.subtle.digest(algorithm, bytes))}`;
}
export async function fetchVerified(url) {
const manifest = await loadManifest();
const key = new URL(url, self.location.href).pathname;
const expected = manifest[key];
if (!expected) throw new Error(`no integrity entry for ${key}`);
const separator = expected.indexOf('-');
const algorithm = ALGORITHMS[expected.slice(0, separator)];
if (!algorithm) throw new Error(`unsupported algorithm in ${expected}`);
const res = await fetch(url, { cache: 'no-store', credentials: 'omit' });
if (!res.ok) throw new Error(`${key}: HTTP ${res.status}`);
const bytes = await res.arrayBuffer();
const actual = await digestOf(bytes, algorithm);
if (actual !== expected) {
throw new Error(`integrity mismatch for ${key}: expected ${expected}, got ${actual}`);
}
return bytes;
}
Verification signal: point fetchVerified() at a file you have deliberately edited by one byte and the console shows Error: integrity mismatch for /assets/worker-a91f.js: expected sha384-…, got sha384-…. If the two strings are identical and it still throws, you are digesting a transformed copy — usually because a proxy re-compressed the response and the browser handed you decompressed bytes that differ from what you hashed at build time.
credentials: 'omit' matters for the same reason crossorigin="anonymous" matters on a <script> tag: it keeps the request from carrying ambient authority and keeps the response cacheable in a way that does not vary per user. The interaction between credentials mode, CORS headers and integrity checking is covered in depth in How CORS and crossorigin Affect SRI; the same rules govern the manual fetch here, with the difference that a mistake produces an opaque response and a confusing TypeError rather than a clear integrity error.
Step 3 — Boot a worker from verified bytes
Permalink to "Step 3 — Boot a worker from verified bytes"With fetchVerified() in place, starting a worker becomes a two-line change: wrap the verified ArrayBuffer in a Blob, mint an object URL, and hand that to the Worker constructor. The blob URL inherits the creating document’s origin, so the worker still sees your origin for fetch, IndexedDB and cookies — it behaves exactly as it did when constructed from /assets/worker.js.
// src/start-worker.js
import { fetchVerified } from './verify.js';
export async function startVerifiedWorker(url, options = {}) {
const bytes = await fetchVerified(url);
const blobUrl = URL.createObjectURL(
new Blob([bytes], { type: 'text/javascript' })
);
const worker = new Worker(blobUrl, options);
// Revoke on teardown, not immediately: the constructor's fetch of the
// blob URL is asynchronous and racing it produces intermittent failures.
const terminate = worker.terminate.bind(worker);
worker.terminate = () => {
terminate();
URL.revokeObjectURL(blobUrl);
};
return worker;
}
const worker = await startVerifiedWorker('/assets/worker-a91f.js');
worker.postMessage({ type: 'ping' });
Verification signal: in DevTools the Sources panel lists the worker under a blob:https://your-origin/… entry rather than under /assets/. That is the confirmation that the running code came from the buffer you verified and not from a second, unverified network request.
Three constraints come with this pattern and each has bitten real deployments. First, the object URL must be allowed by policy: worker-src 'self' blob: is required, because blob: is not covered by 'self'. Second, if you pass { type: 'module' }, static imports inside the worker resolve relative to the blob URL, whose base path is not your asset directory — module workers loaded this way must be bundled into a single self-contained file, or must use absolute URLs for every import. Third, the worker’s own imports are still unverified: a module worker’s import './chunk.js' is a CORS-mode fetch with no integrity metadata, so a code-split worker needs either bundling or a verified loader inside the worker scope.
That last point is the reason importScripts() deserves to be removed rather than patched. It is synchronous, it accepts cross-origin URLs, and — unusually for the platform — it will execute cross-origin script bytes without requiring CORS headers at all. There is no integrity parameter and no way to intercept the bytes between fetch and evaluation. The only safe migrations are to bundle the dependency into the worker entry point at build time, or to convert the worker to a module worker whose imports are all same-origin. The step-by-step migration, including the type: 'module' conversion and the blob wrapper above, is covered in Adding Integrity to Web Worker Scripts.
Step 4 — Verify a WebAssembly module before it is instantiated
Permalink to "Step 4 — Verify a WebAssembly module before it is instantiated"WebAssembly is the context with the widest gap, because .wasm files are commonly served from a CDN, are frequently the largest artifact in the bundle, and are the only one of these contexts with no same-origin restriction at all. WebAssembly.instantiateStreaming(fetch(url), imports) is the recommended-for-performance call in every tutorial, and it performs zero verification.
The simple, always-correct version buffers first:
// src/start-wasm.js
import { fetchVerified } from './verify.js';
export async function instantiateVerified(url, importObject = {}) {
const bytes = await fetchVerified(url);
const module = await WebAssembly.compile(bytes);
return WebAssembly.instantiate(module, importObject);
}
const { instance } = await instantiateVerified('/assets/image-codec-7d20.wasm', {
env: { memory: new WebAssembly.Memory({ initial: 256, maximum: 512 }) }
});
console.log(instance.exports.decode_jpeg);
This gives up streaming compilation, which on a large module can cost hundreds of milliseconds of wall clock. You can get it back without weakening the check by teeing the response body: send one branch to WebAssembly.compileStreaming(), which produces a Module and — critically — does not run the module’s start function, and buffer the other branch for the digest. Instantiation, which is where the start function executes, happens only after the comparison succeeds.
// src/start-wasm-streaming.js
import { digestOf } from './verify.js';
export async function instantiateVerifiedStreaming(url, expected, importObject = {}) {
const res = await fetch(url, { cache: 'no-store', credentials: 'omit' });
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
const [forCompile, forDigest] = res.body.tee();
const modulePromise = WebAssembly.compileStreaming(
new Response(forCompile, { headers: { 'Content-Type': 'application/wasm' } })
);
const bytesPromise = new Response(forDigest).arrayBuffer();
const [module, bytes] = await Promise.all([modulePromise, bytesPromise]);
const actual = await digestOf(bytes, 'SHA-384');
if (actual !== expected) {
throw new Error(`wasm integrity mismatch: expected ${expected}, got ${actual}`);
}
// Only now does anything from the module execute.
return WebAssembly.instantiate(module, importObject);
}
Verification signal: the Network panel shows a single request for the .wasm file (the tee does not duplicate it) and the Performance panel shows Compile Wasm overlapping the download. If you see two network entries, tee() was applied after the body was already consumed elsewhere.
Note the deliberate ordering. WebAssembly.compile() and compileStreaming() validate and compile bytes inside the engine’s own sandbox and produce no observable side effects; WebAssembly.instantiate() links the imports and runs the module’s start function, which is real attacker-controlled execution. Verifying between those two calls is what makes the streaming variant safe rather than merely fast. Deeper coverage of digesting .wasm files, including cross-origin modules and multi-module linking, lives in Verifying WebAssembly Module Hashes.
Step 5 — Service workers, updateViaCache and the Cache API
Permalink to "Step 5 — Service workers, updateViaCache and the Cache API"A service worker is a special case because the platform already performs a byte comparison, and that comparison is routinely mistaken for an integrity check. During an update the browser re-fetches the registered script and compares the response byte-for-byte against the installed copy; if the bytes differ, the new worker installs and enters the waiting state. Since Chrome 68 this comparison extends to scripts pulled in with importScripts() inside the worker. What the algorithm answers is “did this change?” — not “is this the file my build produced?”. An attacker who can serve a different sw.js simply triggers an update and their worker installs successfully.
The one lever the registration exposes is updateViaCache, which controls whether the HTTP cache may satisfy the update fetch. 'imports' is the default and applies the HTTP cache to imported scripts but not to the main script; 'all' applies it to both, and can hide an update for as long as the cache entry lives; 'none' bypasses the HTTP cache for the main script and its imports. Browsers additionally cap the effective freshness lifetime of the service worker script itself at 24 hours, so a runaway max-age=31536000 on sw.js cannot strand clients forever — but 24 hours of serving a compromised or broken bundle is already an incident.
// src/register-sw.js
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
type: 'module',
updateViaCache: 'none' // never satisfy the update check from the HTTP cache
});
registration.addEventListener('updatefound', () => {
const installing = registration.installing;
installing?.addEventListener('statechange', () => {
console.log('service worker →', installing.state);
});
});
}
Pair that with an explicit no-store policy on the worker script at the edge, so the two layers agree:
location = /sw.js {
add_header Cache-Control "no-cache, max-age=0" always;
add_header Service-Worker-Allowed "/" always;
types { application/javascript js; }
}
The authenticity check has to happen inside the worker, at the moment assets enter the Cache API. cache.put() stores whatever Response you hand it, forever, with no validation — a cache poisoned during a single bad install keeps serving compromised bytes long after the origin is fixed. Verify during install, and let a mismatch reject the installation so the old worker keeps serving:
// sw.js (module service worker)
import { digestOf } from '/src/verify.js';
const CACHE = 'app-v42';
const MANIFEST_URL = '/integrity-manifest.json';
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
const res = await fetch(MANIFEST_URL, { cache: 'no-cache' });
if (!res.ok) throw new Error(`manifest: HTTP ${res.status}`);
const manifest = await res.json();
const cache = await caches.open(CACHE);
for (const [path, expected] of Object.entries(manifest)) {
const asset = await fetch(path, { cache: 'no-store', credentials: 'omit' });
if (!asset.ok) throw new Error(`${path}: HTTP ${asset.status}`);
const bytes = await asset.arrayBuffer();
const actual = await digestOf(bytes, 'SHA-384');
if (actual !== expected) {
// Rejecting here aborts installation; the previous worker stays active.
throw new Error(`refusing to cache ${path}: ${actual} != ${expected}`);
}
await cache.put(path, new Response(bytes, { headers: asset.headers }));
}
})());
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
for (const name of await caches.keys()) {
if (name !== CACHE) await caches.delete(name);
}
await self.clients.claim();
})());
});
Verification signal: corrupt one file on the origin and reload. DevTools → Application → Service Workers shows the new worker stuck in installing and then discarded, with the refusing to cache … error in the console, while the previously activated worker continues to serve traffic. That failure mode — refuse the update, keep the known-good version — is the correct one for an integrity control. Cache re-verification on activation, eviction handling and the trade-offs of periodic background checks are expanded in Service Worker Cache Integrity Checks.
Step 6 — CSP as the coarse control
Permalink to "Step 6 — CSP as the coarse control"Content Security Policy cannot express “these exact bytes”, but it can express “only these origins may ever start a worker” and “WebAssembly compilation is or is not permitted here”. That is a valuable outer ring: it removes whole classes of injection rather than pinning individual files, and unlike the digest checks above it is enforced by the browser rather than by code an attacker might already control.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'wasm-unsafe-eval';
worker-src 'self' blob:;
connect-src 'self';
object-src 'none';
base-uri 'none';
require-trusted-types-for 'script'
worker-src governs Worker, SharedWorker and ServiceWorker script URLs. When it is absent the browser falls back to child-src, then script-src, then default-src — which is how policies that never mention workers still end up permitting them from anywhere default-src allows. State it explicitly. Including blob: is what makes the verified-blob pattern from Step 3 work; if you do not use that pattern, drop blob: and close the escape hatch entirely, because blob: in worker-src also permits any injected script to spin up a worker from a string it constructed.
'wasm-unsafe-eval' in script-src permits WebAssembly compilation and instantiation without re-enabling JavaScript eval(). Omit it and WebAssembly.compile() rejects with a CompileError mentioning that the operation is disallowed by the page’s policy. Sites that ship no WebAssembly should leave it out deliberately — that turns “attacker smuggles in a wasm crypto-miner” from a working attack into a policy violation report. The broader interaction between hashes, policy directives and nonces is worked through in Configuring Content Security Policy with SRI.
One directive that will not help here, despite the name: require-sri-for, discussed in Combining require-sri-for with CSP, only governs elements that are capable of carrying the attribute. Worker constructors and WebAssembly calls are not elements, so no value of that directive changes their behaviour.
Configuration Reference
Permalink to "Configuration Reference"| Execution context | Declarative SRI | Origin restriction | Substitute control |
|---|---|---|---|
<script src> / <link rel=stylesheet> |
Yes — integrity + crossorigin |
none | The attribute itself, enforced by fetch |
new Worker(url) (classic) |
No | same-origin top-level script | fetchVerified() → Blob → blob URL; worker-src 'self' blob: |
new Worker(url, {type:'module'}) |
No | same-origin top-level script | Bundle to one file; static imports are CORS-fetched with no integrity |
importScripts(url) |
No | none — executes cross-origin bytes | Remove; bundle at build time or convert to a module worker |
new SharedWorker(url) |
No | same-origin | Same blob-URL pattern; needs worker-src |
navigator.serviceWorker.register() |
No | same-origin, scope-limited | updateViaCache: 'none' + digest check before cache.put() |
WebAssembly.instantiateStreaming() |
No | none — any CORS-enabled origin | compileStreaming() + tee’d SHA-384, instantiate after match |
WebAssembly.compile(bytes) |
No | n/a — bytes already in memory | Digest the buffer before compiling; 'wasm-unsafe-eval' in CSP |
<link rel=preload as=fetch> |
Yes, but advisory | CORS with crossorigin |
Validated, yet a failed preload does not block the later fetch |
Import map integrity key |
Yes, documents only | n/a | No import map exists in worker scopes |
The preload row is worth restating because it is the most common false comfort in this area. The markup below is valid and browsers do check it, but the check protects only the preloaded copy:
<link rel="preload" as="fetch" type="application/wasm"
href="/assets/image-codec-7d20.wasm"
crossorigin="anonymous"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC">
If those bytes fail validation the preload cache entry is dropped and your later fetch() for the same URL goes to the network again, unverified, and succeeds. Use it for warm-up, keep the runtime digest as the control.
Integration with adjacent tooling
Permalink to "Integration with adjacent tooling"The manifest is the integration point, and three neighbouring workflows consume it.
-
Build and CI. Generate the manifest in the same job that produces the bundle, publish it as a build artifact, and diff it between releases. Generating an SRI Manifest in GitHub Actions covers the workflow shape; the only addition needed here is widening the file filter to include
.wasmand the worker entry points, which most SRI plugins skip because they only walk HTML for<script>tags. -
Document-side module loading. Pages that load ES modules can express integrity declaratively through the import map’s
integritykey, described in Using the Import Map integrity Key. Feed it from the same manifest so the document graph and the worker graph agree on digests. The key limitation is worth internalising: import maps are a document feature, and no worker scope has one, so this covers the main thread only. -
Violation telemetry. Digest mismatches thrown by
fetchVerified()are silent unless you report them. Post them to the same collector that receives CSP reports, tagged with the asset path and both digests, so a single dashboard shows attribute-level SRI failures and runtime verification failures side by side. A spike of mismatches on one path across many clients is an edge-cache or origin problem; a spike on one client is usually an extension or a middlebox rewriting responses.
Troubleshooting
Permalink to "Troubleshooting"DOMException: Failed to construct 'Worker': Script at 'https://cdn.example.com/worker.js' cannot be accessed from origin 'https://app.example.com'.
The top-level worker script is fetched with a same-origin request mode, for both classic and module workers, so no CORS header on the CDN response will help. Copy the worker bundle into your own origin at build time, or proxy the path through your own domain. This is not a bug to work around — it is the restriction that makes the rest of this page tractable.
DOMException: Failed to execute 'importScripts' on 'WorkerGlobalScope': The script at 'https://cdn.example.com/lib.js' failed to load.
importScripts() reports every failure with the same message regardless of cause: a network error, a CSP script-src denial, a non-2xx status, or a response whose MIME type the browser refuses. Check the Network panel for the request status first, then the console for an accompanying CSP violation. The permanent fix is not to widen the policy but to bundle the dependency into the worker entry point, since importScripts() cannot be verified at all.
CompileError: WebAssembly.instantiate(): expected magic word 00 61 73 6d, found 3c 21 44 4f @+0
Those four bytes are <!DO — the module URL returned an HTML page, almost always a 404 or a SPA fallback route that serves index.html for unknown paths. Confirm with curl -I that the .wasm path returns 200, and exclude the asset directory from any catch-all rewrite rule. The same signature appears when a CDN serves an error page with a 200 status.
TypeError: Failed to execute 'compileStreaming' on 'WebAssembly': Incorrect response MIME type. Expected 'application/wasm'.
Streaming compilation is strict about the Content-Type header; a server that labels .wasm as application/octet-stream or text/plain fails here even though the bytes are valid. Add the MIME mapping at the edge, or — as in the tee’d example above — wrap the stream in a new Response(stream, { headers: { 'Content-Type': 'application/wasm' } }), which is legitimate precisely because you are about to verify the bytes yourself.
DOMException: Failed to register a ServiceWorker: The script has an unsupported MIME type ('text/html').
Registration refuses any response that is not a JavaScript MIME type. This usually means the sw.js path is being handled by your application router rather than by static file serving, so the SPA shell is returned instead of the worker. It can also mean an authentication middleware is redirecting the request to a login page — service worker script requests carry a Service-Worker: script header you can match on to exempt them.
Clients keep running an old bundle after a successful deploy, with no error anywhere
The service worker is serving from the Cache API and never noticed a new script. Check the response headers on sw.js: a long max-age combined with the default updateViaCache: 'imports' lets the HTTP cache satisfy the imported-script fetches, so the byte comparison sees no change. Set Cache-Control: no-cache on the worker script, register with updateViaCache: 'none', and version the Cache API key on every release so activate can delete the stale one. Until that lands, registration.update() from the console forces a re-check.
Security Boundary Note
Permalink to "Security Boundary Note"Everything on this page verifies that the bytes an execution context receives are the bytes your build produced. It does not do the following:
- It does not establish that the build was trustworthy. A digest pins content, not intent. If a compromised dependency was compiled into the worker bundle, the manifest certifies the malicious file perfectly. Provenance and dependency auditing are separate controls, and the manifest is downstream of both.
- It does not survive a compromised app shell. The verification code, the manifest URL and the comparison all live in JavaScript delivered by the document. An attacker with script execution in the page can replace
fetchVerified()with a function that returns unverified bytes. This is a real, unavoidable limitation of any userland integrity check, and the reason the document’s own<script>tags must still carryintegrityandcrossoriginattributes and be constrained by CSP — the declarative layer is what protects the layer that protects everything else. - It does not sandbox the code it approves. A verified worker still has network access, storage access and the ability to register long-lived handlers. A verified WebAssembly module still receives whatever capabilities your import object grants it. Integrity answers “is this the right code”, never “is this code safe to run”.
- It does not protect a service worker that is already active. Once installed, a worker keeps control until it is replaced or unregistered, and it can serve any response it likes from its cache. Verification at install time is therefore a one-shot gate: get it wrong and the compromised worker outlives the fix on the origin.
- It does not cover data the worker later fetches. Verifying
worker.jssays nothing about the JSON, models or WebAssembly modules the worker pulls at runtime. Each of those needs its own manifest entry and its own check inside the worker scope.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Can I put an integrity attribute on a Web Worker script?
No. The Worker constructor takes a URL and a WorkerOptions dictionary whose only members are type, credentials and name. There is no place to put integrity metadata, and the HTML fetch algorithm for worker scripts passes empty integrity metadata to the fetch. The substitute is to fetch the script yourself, digest the bytes with SubtleCrypto, and construct the Worker from a blob URL built out of the verified bytes.
Does importScripts() enforce Subresource Integrity?
It does not. importScripts() is a synchronous function call inside a classic worker that takes bare URLs and evaluates whatever it receives, with no integrity metadata anywhere in its signature. It is also one of the few APIs that will happily execute cross-origin script bytes without CORS. Treat any importScripts() call to a host you do not control as unverified remote code execution and replace it with a verified fetch.
Why does creating a Worker from a cross-origin URL fail?
Both classic and module worker top-level scripts are fetched with a same-origin request mode, so a CDN URL is rejected before any CORS header is consulted. Chrome reports it as a DOMException saying the script cannot be accessed from the page origin. This is a useful default: it means the only worker code a page can start is code you serve, which is exactly the property a hash manifest then pins down.
How do I verify a WebAssembly module without losing streaming compilation?
Tee the response body. Send one branch to WebAssembly.compileStreaming, which produces a Module without running any start function, and buffer the other branch to compute the SHA-384 digest. Await both, compare the digest against the manifest, and only then call WebAssembly.instantiate on the compiled module. Compilation overlaps the download, and nothing from the module executes until verification has passed.
Does a service worker registration support an integrity check?
No. register() accepts only scope, type and updateViaCache. The script is constrained to be same-origin, and the update algorithm compares the newly fetched script byte-for-byte against the installed copy — that is a change detector, not an authenticity check. Pin the content by verifying every asset against a manifest inside the install handler before writing it into the Cache API.
Is CSP worker-src a substitute for SRI?
No, it is a coarser control that answers a different question. worker-src restricts which origins may be used to start a worker; it says nothing about the bytes those origins return. Use it to close the blob: and data: escape hatches and to forbid third-party worker origins entirely, then layer digest verification on top to pin the actual content.
Related
Permalink to "Related"- Browser Enforcement & Security Boundaries — how the browser actually enforces integrity metadata on the elements that do carry it
- Debugging SRI Hash Mismatch Errors — isolating whether a mismatch came from the origin, an edge transform or a middlebox
- Adding Integrity to Runtime-Injected Scripts — the document-side counterpart, where
script.integrityis still available to you - Layering CSP Nonces, SRI and Trusted Types — assembling the outer policy ring that these runtime checks depend on