Service Worker Cache Integrity Checks

Permalink to "Service Worker Cache Integrity Checks"

Part of Integrity for Workers & WebAssembly, this page shows how to keep every entry in Cache Storage provably identical to what the build produced, using a precache manifest, crypto.subtle.digest(), and an install handler that refuses to activate on a mismatch.

Quick Reference

Permalink to "Quick Reference"
Control Value Effect
navigator.serviceWorker.register(url, opts) { updateViaCache: 'none' } Worker script and its importScripts() targets bypass the HTTP cache
Update check byte-for-byte comparison A single differing byte in sw.js starts a new install
Script HTTP cache lifetime capped at 24 hours max-age above 86400 is clamped for the worker script
crypto.subtle.digest(alg, buffer) 'SHA-256', 'SHA-384', 'SHA-512' Resolves to an ArrayBuffer holding the raw digest
event.waitUntil(promise) on install rejected promise Installation fails, worker goes redundant, nothing activates
cache.put(request, response) rejects only on 206 Will happily store a 404 body — check response.ok yourself
Service-Worker-Allowed e.g. / Response header that widens scope beyond the script’s own path
integrity on register() does not exist There is no integrity option; you hash in JavaScript

Default posture: updateViaCache: 'none', Cache-Control: no-cache on sw.js, SHA-384 digests inlined into the worker, and an all-or-nothing install.

The mental model

Permalink to "The mental model"

A service worker turns Cache Storage into a persistent, origin-scoped, script-controlled copy of your application. Everything good about that is also everything dangerous about it: the entries survive reloads, they are served without touching the network, and they are writable by any same-origin JavaScript that can reach caches.open(). The integrity attribute you would put on a <script> tag has no equivalent here, because nothing in the cache path is markup. Verification has to be something the worker does to itself.

The design that holds up is an all-or-nothing install. The build emits a map of URL to expected digest, that map travels inside the worker script, and during install the worker fetches each asset, reads the bytes, digests them, and compares. Only when every entry has passed does anything reach cache.put(). If one comparison fails, the promise handed to event.waitUntil() rejects, installation fails, the candidate worker transitions to redundant, and the previously activated worker carries on with a cache that was never touched. The failure mode is a stale but honest application rather than a fast but poisoned one, which is the correct trade for a cache that can outlive several deployments.

Choose SHA-384 for the manifest. It is the same algorithm the platform’s own integrity checks favour, it is comfortably clear of the SHA-256 collision-resistance margin, and the digest is only 48 bytes — the reasoning is laid out in SHA-256 vs SHA-384 vs SHA-512 for SRI. Keep the string format identical to an integrity attribute value, sha384- followed by standard base64 of the raw digest, so the same manifest can feed both HTML tags and worker code.

Install-time verification pipeline An asset is fetched with cache reload, read as an ArrayBuffer, digested with SHA-384, and compared with the precache manifest; a match is written with cache.put while a difference rejects the install promise so the worker never activates. fetch(url) cache: 'reload' await res.arrayBuffer() raw bytes, not text crypto.subtle.digest ('SHA-384', bytes) compare with the precache manifest expected: sha384-6lQSXq… digest matches cache.put(req, res) digest differs promise rejects → no activate

The worker script is the root of trust

Permalink to "The worker script is the root of trust"

Every digest in the manifest is only as trustworthy as the file that carries it, so the worker script itself has to be the one thing you defend hardest. Three platform behaviours do most of that work for you, and one registration option decides whether they actually fire.

First, the update algorithm is a byte comparison. When the browser re-fetches sw.js it compares the response body byte for byte with the script it already has installed; if a single byte differs, a new worker is created and install runs. There is no ETag heuristic and no timestamp to spoof. Current Chromium and Firefox extend the same comparison to scripts pulled in through importScripts(), which is why inlining the manifest into the main script is safer than importing it.

Second, the browser clamps how long a worker script may be reused from the HTTP cache. Whatever Cache-Control says, the effective freshness lifetime for the script is capped at 24 hours, so a mis-set max-age=31536000 cannot strand a compromised worker on the client for a year. That cap is a floor on your recovery time, not a strategy — set Cache-Control: no-cache on the script so the cap never matters.

Third, updateViaCache decides whether the HTTP cache is consulted at all during an update check. The default, 'imports', bypasses the cache for the top-level script but still allows cached imports. 'none' bypasses it for the script and everything it imports, which is the only value that makes update timing predictable.

updateViaCache values compared A matrix showing that the value all lets both the worker script and its imports come from a stale HTTP cache, imports lets only the imports go stale, and none keeps both always fresh and is the recommended setting. updateViaCache sw.js from cache imports from cache recommended 'all' may be stale may be stale no 'imports' (default) always fresh may be stale no 'none' always fresh always fresh yes

Registration itself must be same-origin and deliberate. A worker script has to live on the same origin as the page that registers it, its default scope is its own directory, and widening that scope requires the server to send Service-Worker-Allowed. Treat the ability to write files under the registration path as equivalent to full control of the origin, because it is: an activated worker sees every same-origin navigation and every same-origin fetch, indefinitely. Keep the registration behind a small bootstrap file, restrict worker-src in your policy to 'self', and never serve a worker script from a path that user content can reach.

// register-sw.js — the only place registration happens
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js', {
      scope: '/',
      type: 'classic',
      updateViaCache: 'none'
    });
  });
}

Because that bootstrap decides which script gains control of the origin, it is worth hashing like any other entry point:

<script src="/register-sw.js"
        integrity="sha384-uIJ5wOJqZ7stiTmV7KP+RlEvjAtF7F8u1WpoeFoZWDgMEooxD8HPdY7yS224Lrtv"
        crossorigin="anonymous"></script>

Canonical example: an install handler that verifies before it caches

Permalink to "Canonical example: an install handler that verifies before it caches"

The manifest is generated at build time and substituted into the worker source, so there is exactly one file to protect and no second network request that could be intercepted. Digests are formatted exactly as an integrity attribute value would be — the encoding rules are the same ones described in Base64 Encoding Rules for SRI Hashes, standard base64 with padding, never base64url and never hex.

// sw.js — PRECACHE is injected by the build step
const CACHE_NAME = 'app-2026-08-05';
const PRECACHE = {
  '/app.js':      'sha384-6lQSXqYqMSLo3pNjtc/vj/6VHwjhB1fAFiXHmEK8pP56QJX1QFPXHezT0j/QZaUw',
  '/vendor.js':   'sha384-/NQbqVzO4BU3yg4M7Vn5u7XHMXbq+JQfRVNrgWhQ+lavLWjX1K+zr4mhxdEkVoyj',
  '/app.css':     'sha384-7tKRn1YmIq2muX0LoOkcVRxMaC4ybodGq7xzpQtYWSSEQGhFkyoJIQ4k7jP3gQQH',
  '/index.html':  'sha384-9mjwLSicFaQc98MoTyzAfyw+YVp0CILO2iTKueNMnTzLcy8m7w2RMz1C1jmd2sIm'
};

async function sriDigest(buffer) {
  const hash = await crypto.subtle.digest('SHA-384', buffer);
  return 'sha384-' + btoa(String.fromCharCode(...new Uint8Array(hash)));
}

async function fetchVerified(url, expected) {
  const request = new Request(url, { cache: 'reload', credentials: 'same-origin' });
  const response = await fetch(request);

  if (!response.ok) {
    throw new Error(`precache ${url}: HTTP ${response.status}`);
  }
  if (response.type === 'opaque') {
    throw new Error(`precache ${url}: opaque response, body is unreadable`);
  }

  const actual = await sriDigest(await response.clone().arrayBuffer());
  if (actual !== expected) {
    throw new Error(`integrity mismatch for ${url}: expected ${expected}, got ${actual}`);
  }
  return response;
}

self.addEventListener('install', (event) => {
  event.waitUntil((async () => {
    const verified = await Promise.all(
      Object.entries(PRECACHE).map(
        async ([url, expected]) => [url, await fetchVerified(url, expected)]
      )
    );

    // Nothing is written until every asset has passed.
    const cache = await caches.open(CACHE_NAME);
    await Promise.all(verified.map(([url, response]) => cache.put(url, response)));
  })());
});

Three details carry the security of that handler. response.clone() is taken before the body is read, so the original response still has an unconsumed stream when it reaches cache.put(). Promise.all over fetchVerified means the first rejection propagates out of the async function, which rejects the promise passed to waitUntil(), which fails the install — no try/catch is allowed to swallow it. And caches.open() is deliberately called after verification, so a failed install never even creates the cache bucket.

The build step that produces PRECACHE is ordinary Node:

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

const files = ['/app.js', '/vendor.js', '/app.css', '/index.html'];
const manifest = Object.fromEntries(files.map((url) => {
  const digest = createHash('sha384').update(readFileSync(`dist${url}`)).digest('base64');
  return [url, `sha384-${digest}`];
}));

const source = readFileSync('src/sw.js', 'utf8')
  .replace('/*__PRECACHE__*/', JSON.stringify(manifest, null, 2));

writeFileSync('dist/sw.js', source);

Run it after the bundler and before the upload, and wire the same manifest into your release checks — the pattern is covered end to end in Generating an SRI Manifest in GitHub Actions.

Variants

Permalink to "Variants"

Re-validating high-value assets on fetch

Permalink to "Re-validating high-value assets on fetch"

Install-time verification proves the bytes were correct when they were written. It says nothing about the weeks that follow, during which any same-origin script — including one injected through a cross-site scripting flaw — can call cache.put() and overwrite an entry. For a small set of high-value routes, re-hash on the way out.

Fetch-time re-validation decision tree A same-origin fetch either misses the cache and is fetched and verified from the network, or hits the cache; high-value routes are re-hashed, serving the cached body when the digest matches and deleting the entry when it does not. fetch event same-origin not in cache network + verify + put cache hit high-value route? no → serve cached response yes → re-hash and compare respond with the verified body digest ok → serve from cache mismatch → cache.delete + fetch
const HIGH_VALUE = new Set(['/app.js', '/vendor.js']);

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  if (url.origin !== self.location.origin) return;

  event.respondWith((async () => {
    const cache = await caches.open(CACHE_NAME);
    const hit = await cache.match(event.request);
    if (!hit) return fetch(event.request);
    if (!HIGH_VALUE.has(url.pathname)) return hit;

    const expected = PRECACHE[url.pathname];
    const actual = await sriDigest(await hit.clone().arrayBuffer());
    if (actual === expected) return hit;

    await cache.delete(event.request);
    const fresh = await fetchVerified(url.pathname, expected);
    await cache.put(url.pathname, fresh.clone());
    return fresh;
  })());
});

Re-hashing costs a full read of the body on every request for those paths, so keep the set to the few files that would be catastrophic to serve tampered — the main bundle, the payment widget, the auth shim — and let ordinary images and fonts come straight from the cache.

Purging stale caches on activate

Permalink to "Purging stale caches on activate"

Old cache buckets are not merely wasted quota; they are unverified bytes sitting inside your origin, and a future refactor that reads caches.match() across all buckets will happily serve them. Delete everything that is not the current name as the first act of activate.

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    const names = await caches.keys();
    await Promise.all(
      names.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name))
    );
    await self.clients.claim();
  })());
});

Because activate only runs after a successful install, this ordering guarantees the old cache is destroyed only once a fully verified replacement exists.

Verifying the deployed bytes before the worker ever sees them

Permalink to "Verifying the deployed bytes before the worker ever sees them"

The worker checks what the origin served; it cannot tell you whether the origin is serving what you built. Close that gap in the pipeline by re-fetching the deployed URLs and comparing them with the same manifest, as described in Verifying Deployed Assets Against a Hash Manifest. A post-deploy job that fails loudly is far better than a client-side install that fails silently for one user in Sydney.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • cache.addAll() cannot be made to verify anything. It fetches and stores in a single internal step, so your code never sees the bytes and has nowhere to hook a digest. Any precache list built on addAll() is trusting the network unconditionally. Replace it with the explicit fetch, digest, put sequence above, even though it is more code.

  • Opaque responses have no body to hash. A cross-origin request made in no-cors mode yields response.type === 'opaque', whose arrayBuffer() resolves to zero bytes — you would compute the digest of nothing and compare it to a real one forever. Any third-party asset you intend to verify must be served with permissive CORS headers and fetched in cors mode, for the same reasons set out in How CORS and crossorigin Affect SRI.

  • Forgetting crossorigin="anonymous" on the bootstrap tag. A <script> that carries integrity but not crossorigin is fetched in no-cors mode, the browser cannot read the body to check it, and the script is blocked outright — including for same-origin URLs. The console message looks like a hash failure rather than a mode failure; Debugging SRI Hash Mismatch Errors untangles the two.

  • cache.put() will store a failure page. It only rejects outright for a 206 Partial Content response; a 404, a 503 or an edge error page is stored without complaint and served from then on. The digest check catches this incidentally, but check response.ok first so the thrown error names the real cause.

  • Cache Storage is evictable and writable by every same-origin script. The browser may drop the whole bucket under storage pressure, and any XSS payload can call caches.open() and overwrite entries. Call navigator.storage.persist() if eviction hurts, treat the cache as untrusted input on read, and remember that a compromised worker is game over for the origin — recovery means a replacement sw.js that unregisters itself plus a Clear-Site-Data response from the origin.

Verification Steps

Permalink to "Verification Steps"

1. Confirm the worker script is never served stale

Permalink to "1. Confirm the worker script is never served stale"
curl -sI https://example.com/sw.js | grep -iE 'cache-control|content-type'

Expected output — no-cache forces revalidation on every update check, well inside the browser’s 24-hour cap:

cache-control: no-cache
content-type: text/javascript; charset=utf-8

2. Confirm the registration options took effect

Permalink to "2. Confirm the registration options took effect"

Run this in the page console, not the worker console:

const reg = await navigator.serviceWorker.getRegistration('/');
console.log(reg.updateViaCache, reg.scope, reg.active?.scriptURL);

Expected output:

none https://example.com/ https://example.com/sw.js

3. Prove a poisoned asset aborts the install

Permalink to "3. Prove a poisoned asset aborts the install"

Mutate a file after the manifest was generated, then reload with a hard refresh so the worker re-installs:

printf '\n/* tampered */\n' >> dist/app.js

The worker console reports the thrown error and the new worker never reaches activated:

Uncaught (in promise) Error: integrity mismatch for /app.js:
  expected sha384-6lQSXqYqMSLo3pNjtc/vj/..., got sha384-Rk9wZmJ1...

In DevTools under Application → Service Workers the candidate shows as redundant, and the previously activated worker is still listed as controlling the page — exactly the outcome you want.

4. Confirm only the current cache survives

Permalink to "4. Confirm only the current cache survives"
console.log(await caches.keys());

Expected output after a clean activate:

['app-2026-08-05']

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Can I put an integrity attribute on a service worker registration?

No. The options bag accepted by navigator.serviceWorker.register() carries only scope, type and updateViaCache; there is no integrity key, and no HTML element loads the worker script. The browser substitutes its own guarantee: the script must be same-origin, it is fetched over HTTPS, and every update is byte-compared against the stored copy.

Why hash cached responses when the transport is already HTTPS?

TLS protects the hop, not the object. A compromised build, a poisoned edge cache, a rewritten object in origin storage, or any same-origin script calling caches.open() can put attacker bytes into Cache Storage over a perfectly valid TLS connection. Hashing against a manifest that was fixed at build time is what turns transport security into content verification.

What happens to the existing cache if the install handler rejects?

Nothing. A rejected waitUntil promise fails installation, the new worker moves to the redundant state and is discarded, and the previously activated worker keeps serving with its own cache untouched. That is why the install handler should write only after every digest has been checked, so a partial write cannot survive the failure.

Does updateViaCache set to none hurt performance?

Barely. It forces one conditional request for the worker script and its imports on each update check, and update checks are already throttled by the browser. The script is typically a few kilobytes and the response is usually a 304. Every asset the worker precaches still comes from Cache Storage, so the user-visible load path is unaffected.

How do I recover from a malicious service worker that already activated?

Deploy a replacement sw.js at the same URL that calls skipWaiting, deletes every cache and then calls registration.unregister(). Because the update algorithm compares bytes, the new script installs as soon as a client checks. Send Clear-Site-Data with the storage and executionContexts directives from the origin as well, and rotate whatever credentials the worker could read.

Permalink to "Related"

Related Articles

Adding Integrity to Web Worker Scripts
Verifying WebAssembly Module Hashes
Integrity for Workers & WebAssembly Core SRI Fundamentals & Browse…