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.
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.
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.
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 onaddAll()is trusting the network unconditionally. Replace it with the explicit fetch, digest,putsequence above, even though it is more code. -
Opaque responses have no body to hash. A cross-origin request made in
no-corsmode yieldsresponse.type === 'opaque', whosearrayBuffer()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 incorsmode, for the same reasons set out in How CORS and crossorigin Affect SRI. -
Forgetting
crossorigin="anonymous"on the bootstrap tag. A<script>that carriesintegritybut notcrossoriginis fetched inno-corsmode, 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 a206 Partial Contentresponse; a404, a503or an edge error page is stored without complaint and served from then on. The digest check catches this incidentally, but checkresponse.okfirst 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. Callnavigator.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 replacementsw.jsthat unregisters itself plus aClear-Site-Dataresponse 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.
Related
Permalink to "Related"- Adding Integrity to Web Worker Scripts — why
new Worker()accepts no integrity attribute, and the blob and module patterns that work around it - Verifying WebAssembly Module Hashes — digesting a
.wasmpayload before instantiation, including the streaming compilation trade-off - Configuring Content Security Policy with SRI — locking
worker-srcandscript-srcdown so only your worker script can ever register