Adding Integrity to Web Worker Scripts

Permalink to "Adding Integrity to Web Worker Scripts"

Part of Integrity for Workers & WebAssembly, this page explains why new Worker('/worker.js') has nowhere to put a hash and shows the two patterns production code actually uses to close that gap.

Quick Reference

Permalink to "Quick Reference"
Mechanism Syntax Declarative integrity Algorithm you control Support
Classic script element <script src integrity crossorigin> Yes — SRI applies sha256, sha384, sha512 All current browsers
Classic worker new Worker(url) None All current browsers
Module worker new Worker(url, { type: 'module' }) None Chrome 80+, Safari 15+, Firefox 114+
Nested worker imports importScripts(url) None Classic workers only
Verified blob worker new Worker(URL.createObjectURL(blob)) Manual, in your code SHA-384 via crypto.subtle.digest Secure contexts only
Fetch-level check fetch(url, { integrity: 'sha384-…' }) Yes — browser enforced sha256, sha384, sha512 Current browsers

Default choice: keep workers same-origin and pin them with a build manifest; use fetch-verify-blob when the worker source is cross-origin.

The mental model

Permalink to "The mental model"

Subresource Integrity is an attribute on an element. The specification hangs the check off the integrity content attribute of <script> and <link>, and the fetch machinery consults it when the element requests its resource. A worker is not an element. new Worker(url) is a constructor call whose second argument is an options bag holding type, credentials and name — no integrity key exists, and no CSP directive supplies one. The browser fetches the worker script and runs it, unverified, in a thread that has network access, IndexedDB access and the ability to spawn further workers.

That gap matters more than it looks, because the worker’s fetch is also invisible to the controls people usually rely on. A classic worker script must be same-origin: new Worker('https://cdn.example.com/w.js') throws a SecurityError at construction time, which pushes teams toward proxying third-party worker code through their own origin — and a proxied file gets no more scrutiny than any other same-origin asset. Meanwhile the one place SRI is available on a script element is often skipped by exactly the developers who most need it:

<!-- A script element can declare its own hash. A worker cannot. -->
<script src="https://cdn.example.com/lib.v4.js"
        integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
        crossorigin="anonymous"></script>

So the pattern is to move the check one step earlier: fetch the worker source yourself, hash the bytes you received, compare against a value you fixed at build time, and only then hand those same bytes to the Worker constructor by way of a Blob. The browser never sees an integrity attribute; your code performs the equivalent check and refuses to construct the worker on mismatch.

Fetch, digest, compare, construct Worker source is fetched with CORS, read as an ArrayBuffer, digested with SHA-384 and base64 encoded, then compared with the expected literal; a mismatch throws and no worker is created, while a match produces a Blob URL passed to the Worker constructor. fetch(url) mode: 'cors' response .arrayBuffer() crypto.subtle .digest('SHA-384') base64 encode 48-byte digest compare with expected sha384- literal mismatch: throw no Worker is created match: Blob URL new Worker(blobUrl)

Two details decide whether the check is real or theatre. First, the fetch must be CORS-readable: a request made with mode: 'no-cors' yields an opaque response whose arrayBuffer() is empty, so you would hash zero bytes and happily compare them against nothing useful. The same class of mistake — a cross-origin fetch without the right response headers — is covered in How CORS and crossorigin Affect SRI. Second, you must hash and execute the same bytes. Reading the body as text and re-encoding it into a Blob can change the byte sequence, at which point the digest you verified is not the digest of what runs.

Canonical example: verifyAndStartWorker()

Permalink to "Canonical example: verifyAndStartWorker()"

A complete implementation. It parses an SRI-format string so the expected value can come straight out of a build manifest, digests the fetched buffer with the algorithm named in that string, base64-encodes the raw digest bytes, and constructs the worker only on an exact match.

// verify-worker.js — main thread, secure context required for crypto.subtle
const ALGORITHMS = { '256': 'SHA-256', '384': 'SHA-384', '512': 'SHA-512' };

function parseIntegrity(value) {
  const match = /^sha(256|384|512)-([A-Za-z0-9+/]+={0,2})$/.exec(String(value).trim());
  if (!match) throw new Error(`unsupported integrity string: ${value}`);
  return { algorithm: ALGORITHMS[match[1]], expected: match[2], prefix: `sha${match[1]}` };
}

function bytesToBase64(bytes) {
  let binary = '';
  for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
  return btoa(binary);
}

export async function verifyAndStartWorker(url, integrity, workerOptions = {}) {
  const { algorithm, expected, prefix } = parseIntegrity(integrity);

  const response = await fetch(url, { mode: 'cors', credentials: 'omit' });
  if (!response.ok) throw new Error(`worker fetch failed: ${response.status} ${url}`);

  // Hash exactly the bytes that will be executed — never re-encode via text().
  const source = await response.arrayBuffer();
  const digest = await crypto.subtle.digest(algorithm, source);
  const actual = bytesToBase64(new Uint8Array(digest));

  if (actual !== expected) {
    throw new Error(`integrity mismatch for ${url}: computed ${prefix}-${actual}`);
  }

  const blobUrl = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
  const worker = new Worker(blobUrl, workerOptions);
  // The constructor has already started fetching the blob; release the handle next task.
  setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
  return worker;
}

Call it exactly where you would have called the constructor:

import { verifyAndStartWorker } from './verify-worker.js';

const worker = await verifyAndStartWorker(
  'https://cdn.example.com/analytics-worker.v3.js',
  'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC',
  { name: 'analytics' },
);
worker.postMessage({ type: 'init' });

The base64 step is the part people get wrong most often: crypto.subtle.digest returns an ArrayBuffer of raw bytes — 48 of them for SHA-384 — and an SRI value is the standard base64 encoding of exactly those bytes, not of their hex representation. Encoding hex instead produces a 96-character string that will never match anything a build tool emits. The full rules, including padding and which alphabet applies, are in Base64 Encoding Rules for SRI Hashes.

Two policy directives have to permit this. connect-src must allow the origin you fetch from, and worker-src must include blob: — a document whose policy is worker-src 'self' will refuse the constructed worker outright. Blob workers inherit the creating document’s policy rather than escaping it, which is why widening worker-src here is a narrower change than it sounds; the interaction between script policy and hash enforcement is covered in Configuring Content Security Policy with SRI.

add_header Content-Security-Policy "default-src 'self'; worker-src 'self' blob:; connect-src 'self' https://cdn.example.com" always;

The object URL’s lifecycle is short and specific: it exists only long enough for the constructor to claim it.

Blob URL lifecycle around worker construction The page registers a blob and receives an object URL, passes that URL to the Worker constructor, then revokes it; the worker thread keeps running because its script was already fetched. page (main thread) blob URL registry worker thread createObjectURL(blob) returns blob: URL new Worker(blobUrl) revokeObjectURL(url) script already fetched worker keeps running

Variants

Permalink to "Variants"

Let the browser do the hashing with fetch()

Permalink to "Let the browser do the hashing with fetch()"

The Fetch API’s integrity option accepts the same sha384-… string an element would, and the browser enforces it. The request rejects with a TypeError on mismatch, so no bad bytes ever reach your code.

const response = await fetch(url, {
  mode: 'cors',
  credentials: 'omit',
  integrity: 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC',
});
const worker = new Worker(URL.createObjectURL(await response.blob()));

This is shorter and moves the comparison into the platform, but it gives you no diagnostic beyond a generic rejection. Keep the manual crypto.subtle path when you need the computed digest in an error report or a telemetry event, or when you must support engines that predate the option.

Module workers and their import graph

Permalink to "Module workers and their import graph"

A module worker is constructed with { type: 'module' } and supports static import inside the worker script:

const worker = new Worker('/assets/pipeline.worker.mjs', { type: 'module' });

Verifying the entry file proves nothing about its imports. Each specifier the module loader resolves is a separate request with no hash attached, and import maps — the one place a declarative integrity key exists for modules, described in SRI for ES Module Imports — are a document-level feature that a worker’s module graph does not consult. There is a second trap when combining module workers with blob URLs: a blob: URL has an opaque path, so a relative specifier such as ./util.js cannot be resolved against it and the import fails. Bundle the worker into a single self-contained file. One file means one URL, one hash, and no unverified edges in the graph.

Same-origin workers pinned by a build manifest

Permalink to "Same-origin workers pinned by a build manifest"

When the worker ships from your own origin, the cheaper pattern is to never fetch it in JavaScript at all: emit its digest at build time and enforce that digest in the pipeline. The build writes a manifest beside the assets:

{
  "/assets/pipeline.worker.8f3a1c.js": "sha384-9wYQlGYl1kPzQho1wx4JwY8wCoqVuAfXRKap7fdgcCY5uykM6+R9GqQ8Kuxy9rx7",
  "/assets/analytics.worker.2b90de.js": "sha384-Kap7fdgcCY5uykM6+R9GqQ8Kuxy9rx79wYQlGYl1kPzQho1wx4JwY8wCoqVuAfXR"
}

CI then re-hashes what is actually deployed and compares:

#!/usr/bin/env bash
set -euo pipefail
base="https://www.example.com"
jq -r 'to_entries[] | "\(.key)\t\(.value)"' dist/worker-integrity.json |
while IFS=$'\t' read -r path expected; do
  actual="sha384-$(curl -fsSL "${base}${path}" | openssl dgst -sha384 -binary | openssl base64 -A)"
  if [ "$actual" != "$expected" ]; then
    echo "drift: ${path}" >&2
    echo "  expected ${expected}" >&2
    echo "  actual   ${actual}" >&2
    exit 1
  fi
done
echo "all worker bundles match the manifest"

The runtime cost is zero and the guarantee is different in kind: instead of the browser refusing a bad file, the pipeline refuses a bad deploy. The same manifest discipline applied to every shipped asset is the subject of Verifying Deployed Assets Against a Hash Manifest.

The three loading mechanisms differ only in who, if anyone, is holding the hash:

Where the hash check lives per loading mechanism A matrix comparing a script element, a classic worker, module worker static imports, importScripts and the fetch-digest-Blob pattern across declared hash, who checks it, and the outcome when bytes are tampered with. load mechanism declared hash who checks it tamper outcome script tag with integrity yes the browser load blocked new Worker(url) none nobody executes anyway module worker static imports none nobody executes anyway importScripts(url) none nobody executes anyway fetch + digest + Blob URL manual your code you abort

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • importScripts() is the weakest link in the whole chain. It is synchronous, accepts only URLs, takes no options object, and executes each response the instant it arrives inside the worker’s global scope. It also happily loads cross-origin URLs, so a compromised third party gets direct code execution in a thread you never inspect. Where you cannot remove it, replace the call with a fetch-verify-blob step performed inside the worker itself — fetch, crypto.subtle and URL.createObjectURL are all available in worker scope — and pass the resulting object URL to importScripts().

  • Omitting crossorigin="anonymous" breaks integrity on script elements. When an integrity attribute is present but the element makes a plain no-CORS request, the response is opaque, the browser cannot read it to hash it, and it blocks the resource as an integrity failure. The worker equivalent is fetching with mode: 'no-cors': arrayBuffer() returns zero bytes and your comparison becomes meaningless rather than merely wrong. Always pair the two.

  • Hashing the string, not the bytes. await response.text() followed by new Blob([text]) re-encodes through UTF-16 to UTF-8 and normalises nothing else; any byte-order mark, lone surrogate or unusual encoding declared by the server produces a different byte sequence than the one you hashed. Digest the ArrayBuffer and build the Blob from that same buffer, as the canonical example does.

  • A verified worker is still your origin’s code. The blob URL inherits the creating document’s origin, so the third-party worker you just verified runs with access to your IndexedDB, your cookies via same-origin fetches, and your localStorage through the main thread. Verification proves the bytes are the ones you reviewed; it says nothing about whether those bytes deserve that privilege.

  • A mismatch you cannot explain is usually a transform, not an attack. Edge compression, HTML-to-JS minifying proxies and CDN “optimisation” features rewrite response bodies and change the digest. Before assuming compromise, walk the same diagnosis path used for element-level failures in Debugging SRI Hash Mismatch Errors.

Verification Steps

Permalink to "Verification Steps"

1. Produce the expected digest from the built file

Permalink to "1. Produce the expected digest from the built file"
openssl dgst -sha384 -binary dist/assets/pipeline.worker.8f3a1c.js | openssl base64 -A

Expected output is a 64-character base64 string with no padding, for example:

9wYQlGYl1kPzQho1wx4JwY8wCoqVuAfXRKap7fdgcCY5uykM6+R9GqQ8Kuxy9rx7

Prefix it with sha384- to get the value verifyAndStartWorker() expects.

2. Confirm the browser computes the same value

Permalink to "2. Confirm the browser computes the same value"

Paste this into the DevTools console on the page that loads the worker:

const buf = await (await fetch('/assets/pipeline.worker.8f3a1c.js')).arrayBuffer();
const d = await crypto.subtle.digest('SHA-384', buf);
console.log('sha384-' + btoa(String.fromCharCode(...new Uint8Array(d))));

The logged string must equal the manifest entry character for character. A different length means you encoded hex instead of raw bytes.

3. Prove that tampering aborts the construction

Permalink to "3. Prove that tampering aborts the construction"

Append a single byte to the deployed file, reload, and check the console. The expected result is a thrown error and no worker:

Error: integrity mismatch for /assets/pipeline.worker.8f3a1c.js: computed sha384-…

Confirm in the DevTools Sources panel that no worker thread appears under Threads.

4. Confirm the policy permits the blob worker

Permalink to "4. Confirm the policy permits the blob worker"

Temporarily set worker-src 'self' and reload. Chrome logs:

Refused to create a worker from 'blob:https://www.example.com/…' because it violates
the following Content Security Policy directive: "worker-src 'self'".

Restore worker-src 'self' blob: and confirm the message disappears and the worker’s first message event fires.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Can I put an integrity attribute on new Worker()?

No. Subresource Integrity is defined for elements that carry an integrity content attribute, and the Worker constructor takes a URL plus an options object with type, credentials and name only. There is no integrity key. The constructor fetches and executes the script with no hash check of any kind, so verification has to happen in your own code before the worker exists.

Does a Blob URL worker bypass my Content Security Policy?

No. A worker created from a blob: URL inherits the policy of the document that created it, so the worker cannot do anything your page’s policy forbids. What you do have to change is the parent policy: worker-src must list blob:, otherwise the browser refuses to create the worker and logs a violation naming the blob URL.

Do static imports inside a module worker inherit the parent's integrity check?

They do not. Verifying the entry module proves only that one file is authentic. Every import specifier it resolves is fetched by the module loader as a separate request with no hash attached, and import maps are a document-level feature that worker module graphs do not consult. Bundle worker code into a single file so there is exactly one URL to pin.

Is importScripts() safe if the script is same-origin?

Same-origin narrows the attacker set to whoever can write to your origin, which is better than trusting a third party, but importScripts() itself checks nothing. It is synchronous, takes no options object, and executes the response the moment it arrives. Treat it as a build-time dependency: pin the file with a manifest hash and verify the deployed bytes in CI.

Should I revoke the blob URL immediately after constructing the worker?

Revoke it, but not inside the same synchronous statement. The Worker constructor starts an asynchronous fetch of the blob, so queue the revocation as a separate task with setTimeout or revoke on the worker’s first message. Skipping revocation entirely keeps the blob’s bytes alive until the document unloads, which leaks memory in long-lived single-page applications.

Permalink to "Related"

Related Articles

Verifying WebAssembly Module Hashes
Service Worker Cache Integrity Checks
Integrity for Workers & WebAssembly Core SRI Fundamentals & Browse…