A progressive web app installs a programmable proxy, the service worker, between your users and your origin, and that proxy keeps running and keeps serving cached content long after the page that registered it has closed. That is what makes offline use, fast start-up and push notifications possible, and it is also why a single flaw can outlive a fix on the server. This guide covers the parts of a PWA that ordinary web security reviews tend to miss: service worker registration and scope, the update lifecycle, the Cache API, the web app manifest, push subscriptions and the kill switch you need when something goes wrong. It is written for web developers, application security engineers and penetration testers who already know the browser basics. For the wider picture of scripts running in users' browsers, see the Client-Side Security Guide.
Key takeaways
- A service worker is a persistent, origin-wide man in the middle. Its fetch handler sees every request from controlled pages, including cross-origin sub-resource requests, and can answer any of them with a response it builds itself.
- Scope is your blast radius. Register the worker at the narrowest path that works, never widen scope with the Service-Worker-Allowed header without review, and treat any user-controlled file served from your origin as a potential worker script.
- Caching rules decide what leaks and what goes stale. Never cache authenticated or personalised responses in a shared cache name, version every cache, and delete old caches in the activate handler.
- Plan the kill switch before launch. A no-op worker at the same URL and a Clear-Site-Data "storage" response from logout or an incident endpoint are what let you remove a bad worker from devices you cannot touch.
- Push subscriptions are credentials. The subscription endpoint is a capability URL and the VAPID private key signs every message, so both need the handling you give API keys.
How a PWA changes the threat model
A PWA is still a website: the same origin, the same cookies, the same HTML and JavaScript. What changes is persistence and interception. Three browser features do most of the work, and each moves a security property.
| Component | What it does | Security property it changes | Main risk |
|---|---|---|---|
| Service worker | Event-driven script that intercepts fetch, push and sync events for pages in its scope | Integrity of every in-scope response; persistence after the tab closes | A malicious or buggy worker controls traffic until it is replaced or unregistered |
Cache API (caches) | Origin-scoped store of request and response pairs that the worker reads and writes | Confidentiality of cached responses; freshness of code | Personal data left on shared devices; stale vulnerable JavaScript served from cache |
| Web app manifest | JSON file describing name, icons, start_url, scope, id and display mode | How the app is presented when installed, often without a visible address bar | Spoofed look and feel; identifiers placed in start_url |
| Push API and notifications | Lets your server wake the worker and show notifications through a browser push service | A new server-to-device channel authenticated by keys | Leaked endpoints or VAPID keys allow unwanted or abusive notifications |
MDN's service worker documentation is explicit that a fetch event fires for every resource request in scope, including a cross-origin image embedded by an in-scope page, and that the handler can supply any response through respondWith(). A compromised worker therefore has the same reach as a compromised reverse proxy, but runs on the user's device where your server logs cannot see it. In ATT&CK terms the closest fits are T1189 Drive-by Compromise for the initial script injection and T1185 Browser Session Hijacking for what a hostile worker can do with an authenticated session.
Registration and scope: keep the blast radius small
The browser enforces several rules at registration, and they are your first line of defence. Your job is to avoid weakening them.
- Secure context only: the ServiceWorker, ServiceWorkerRegistration and ServiceWorkerContainer interfaces are marked
[SecureContext]in the W3C specification, so registration fails over plain HTTP. Browsers treatlocalhostas secure to help development, which is a reason to keep development builds off shared hostnames. - Same-origin script: the worker script must come from the registering page's origin, and the worker runs in that origin. A third-party CDN cannot host your worker directly.
- Default scope is the script's directory: a worker at
/js/sw.jscontrols only URLs under/js/unless the server sends aService-Worker-Allowedheader that permits a broader path. Registration with ascopebroader than the script path allows fails. - Several registrations per origin: the specification allows many registrations at one origin as long as the scope URLs differ, and registering an identical scope replaces the existing registration. That replacement rule is useful for recovery and dangerous if an attacker can register first.
- Trusted Types: the specification passes the script URL through Trusted Types before parsing, so a Trusted Types policy can restrict which URLs are allowed to become workers.
Why same-origin is not enough
The same-origin rule means an attacker cannot register a worker from their own domain. It does not stop them using any endpoint on your origin that returns attacker-influenced JavaScript, for example a user upload served from the same host, a JSONP callback parameter, or an open path that reflects input with a script-compatible content type. If such an endpoint sits at the root, a single cross-site scripting flaw can register a root-scoped worker that survives after the XSS is patched. Controls that close this gap:
- Serve user uploads from a separate, cookieless origin, never from the application origin.
- Set a
Content-Security-Policythat includes aworker-srcdirective listing only your worker script path, so injected code cannot register a different script. - Return
X-Content-Type-Options: nosniffand correctContent-Typevalues so data endpoints cannot be loaded as scripts. - Strip or reject a
Service-Worker-Allowedheader anywhere it is not deliberately set, and alert on it in configuration review.
See the Content Security Policy Guide and the Cross-Site Scripting (XSS) Prevention Guide for the general controls.
The update lifecycle and why fixes can be slow
Server-side fixes take effect on the next request. Service worker fixes take effect only when the browser installs and activates the new worker, and the lifecycle deliberately delays that.
| Stage | What happens | Security implication |
|---|---|---|
| Update check | The browser checks for a new script on navigation and functional events; a registration counts as stale when the last check was more than 86,400 seconds (24 hours) ago | A user who keeps a tab open can run old code; the 24 hour rule bounds how long the HTTP cache can delay discovery |
| install | New worker downloads and pre-caches assets; if the waitUntil() promise rejects, installation fails | A failed install leaves the old, possibly vulnerable, worker in control |
| waiting | New worker waits while the old version still controls open pages | Security fixes sit idle until every controlled tab closes |
| activate | New worker takes over and should delete old caches | Old caches left behind keep stale code and data |
skipWaiting() and clients.claim() | Force immediate activation and take control of already open pages | Use for security releases; test for pages running mixed versions |
- updateViaCache: the registration option accepts
imports(the default),allornone. Setnoneso update checks for the worker and its imported scripts bypass the HTTP cache, and servesw.jswithCache-Control: no-cache. - Imported scripts: code pulled in through
importScripts()runs with the worker's full privileges. Host it on your origin, version it in the file name and avoid importing third-party scripts into the worker at all. - Global state: MDN warns that one worker serves many pages and global variables are shared, so never hold per-user secrets in worker globals.
Cache API: what to cache and what never to cache
The Cache API is a persistent, origin-wide store. MDN notes that cache contents are kept until you clear them, so anything you put there outlives the session unless your code removes it. Most PWA data exposure comes from caching strategies copied from tutorials that cache every response.
| Content | Recommended strategy | Why |
|---|---|---|
| Versioned static assets (hashed JS, CSS, fonts) | Cache first, pre-cached at install, cache name tied to release | Content is immutable for that version; hash in the name prevents mixing releases |
| App shell HTML | Network first with cached fallback | Gets fixes quickly while still working offline |
| Public API data | Stale while revalidate with short lifetime | Acceptable to show slightly old data |
| Authenticated or personalised API responses | Network only, or a per-user cache cleared at logout | A shared cache can serve one user's data to the next user of a device |
Responses marked Cache-Control: no-store | Never cache; respect the header in your fetch handler | The Cache API does not apply HTTP caching rules for you |
| Error, redirect and opaque responses | Do not cache; check response.ok before cache.put() | Caching a 500 page or a login redirect breaks the app offline and can pin an attacker-influenced response |
Cache poisoning inside the worker
Cache poisoning in a PWA usually means the worker stores a response it should not have trusted, then serves it repeatedly. Typical routes are caching cross-origin responses from a CDN without integrity checks, keying cache entries on URLs that include attacker-controlled query parameters, or caching a response delivered while the user was on a hostile network before HSTS was in place. Defences are to cache only same-origin or integrity-verified assets, normalise cache keys to an allow list of paths, apply HSTS before the first visit where possible, and rotate cache names on every release. OWASP ASVS 5.0 requirement V3.6.1 asks that externally hosted assets be static, versioned and protected with Subresource Integrity; apply the same rule to anything the worker pre-caches. See the Subresource Integrity (SRI) Guide.
Sensitive data and shared devices
ASVS 5.0 V14.3.1 requires authenticated data to be cleared from client storage when the session ends, and names the Clear-Site-Data header as a help; V14.3.2 requires Cache-Control: no-store on sensitive responses; V14.3.3 limits what may sit in browser storage. In a PWA that means your logout flow must delete per-user caches and IndexedDB stores as well as cookies. The Browser Storage Security Guide covers token placement.
The web app manifest and installed app presentation
The manifest controls how an installed PWA looks and launches. The W3C specification is a Working Draft (6 November 2026 at the time of checking) and describes itself as not stable, so treat the details below as current behaviour that may change.
- start_url: must be same origin as the document; otherwise the browser falls back to the document URL. Do not put user identifiers in it: the specification's privacy section notes that data encoded in start_url is not cleared when a user clears site data.
- scope: navigation outside scope drops the app back to a normal browser view. The specification recommends always declaring scope, preferably
"/", because users can install from any page. Security-wise, keep login and payment flows inside scope so users are not bounced to a view that looks different mid-flow. - id: the application's identity, used to decide whether a manifest is a new app or an update. It must be same origin as start_url. Changing it creates a different installed app.
- name, short_name and icons: the specification lists these as security-sensitive; user agents should present changes to the user and get express permission before applying them. Expect users to see a prompt if you rebrand.
- display:
standaloneandfullscreenhide the address bar, so users cannot check the origin. Never ask for credentials in a view that could be mistaken for another app, and give users a visible way to see where they are. - Cross-origin manifests: a manifest on a different origin must be fetched with CORS, and the credentials mode follows the link element's
crossoriginattribute. Host the manifest on your own origin unless you have a reason not to.
Push notifications and their keys
Web Push has three keys or secrets, and each needs an owner. The application server identifies itself to the browser vendor's push service with VAPID (RFC 8292): it signs a JWT with an ECDSA P-256 key using ES256 and sends it in an Authorization: vapid t=..., k=... header. The JWT carries an aud claim with the push service origin, an exp that must not be more than 24 hours from the request, and an optional sub contact URI.
| Secret | Where it lives | If it leaks | Control |
|---|---|---|---|
| VAPID private key | Your push sending service | Anyone can send pushes to subscriptions restricted to your key | Store in a secrets manager or HSM, restrict which workloads can sign, plan rotation (RFC 8292 advises gradual migration because new keys break linkability) |
| Subscription endpoint URL | Browser, then your subscription database | MDN describes it as a capability URL: anyone who has it can target that user | Encrypt the database column, never log full endpoints, delete on unsubscribe or 404/410 responses |
Subscription p256dh and auth values | Browser, then your subscription database | With the endpoint, allows sending readable messages to that user | Treat the whole subscription object as a credential tied to the user record |
| Signed VAPID JWT | In transit to the push service | RFC 8292 notes a stolen token can be replayed until it expires | Short expiry, HTTPS only |
- CSRF on subscription registration: MDN stresses protecting the request that sends the subscription to your server against cross-site request forgery; otherwise an attacker can bind their own subscription to a victim's account.
- Restricted subscriptions: always pass
applicationServerKeywhen subscribing, so the push service only accepts messages signed with your key. - Content discipline: notifications appear on lock screens. Do not put one-time codes, balances or health details in the notification body; send a prompt to open the app instead.
- Permission prompts: request notification permission after a user action that explains the benefit, never on first load. Abusive prompting is how push becomes a spam and phishing channel.
Where identity fits
Two identities matter in a PWA. The user's session usually lives in cookies or tokens that the worker can see in every request it proxies, so an attacker who controls the worker can ride the session without stealing it; short session lifetimes and server-side revocation limit the damage. The second is a non-human identity: the push sending service holds the VAPID private key and often API credentials to your subscription store. Inventory it, give it its own workload identity rather than a shared application secret, and rotate it on staff changes. See the Session Hijacking and Cookie Theft Guide.
Implementation: a phased approach
- Inventory: list every origin and path that registers a worker, every cache name in use and every service that sends push. Use the browser developer tools' Application panel on each environment to see registrations and caches.
- Build the kill switch first: prepare a minimal worker that installs, calls
skipWaiting(), deletes all caches and unregisters itself, deployable at the same script URL. Add a server route that returnsClear-Site-Data: "cache", "storage"; MDN documents that the"storage"directive unregisters service workers for the origin and that the header only works in secure contexts. - Harden registration: narrow scope, add
worker-srcto CSP, move uploads to a separate origin, setupdateViaCache: 'none'andCache-Control: no-cacheon the worker script. - Rewrite caching rules: classify routes using the table above, add
response.okand same-origin checks before caching, and add per-user cache clearing to logout. - Pilot: release to a small cohort or internal users, confirm the new worker activates, old caches are deleted and logout leaves no personal data in Cache Storage or IndexedDB.
- Test the rollback: deploy the kill switch in staging, confirm it removes the worker from a device that was offline during the release, then restore the normal worker.
- Add to the SDLC: include the worker script, manifest and push service in code review, dependency scanning and penetration test scope.
Small and large teams
A small team gets most of the value from three things: narrow scope, no caching of authenticated responses and a tested kill switch. Larger organisations with several apps on one origin should give each app its own path-scoped worker and cache prefix, centralise VAPID keys in a secrets manager, and add worker and manifest changes to change approval.
Detection and measurement
A worker runs on the client, so you need signals you can collect server-side or from the page.
- Worker version beacon: have the worker add a version header to its own requests or post its version on activation, and alert when unexpected versions appear.
- CSP reports: ASVS V3.4.7 asks for a CSP report location;
worker-srcviolations indicate an attempt to register an unapproved script. - Header monitoring: alert on any response carrying
Service-Worker-Allowedoutside approved paths. - Push service responses: rising 401 or 403 responses from push services indicate key problems; 404 or 410 means stale subscriptions to delete.
| Metric | Target |
|---|---|
| Share of active clients on the latest worker version 72 hours after a security release | Above 95 percent |
| Routes returning authenticated data that are cached by the worker | Zero |
| Time to deploy and confirm the kill switch in an exercise | Under one hour |
| Worker or manifest changes merged without security review | Zero |
| Age of the VAPID key against your rotation policy | Within policy |
Framework mapping
| Control area | NIST SP 800-53 Rev. 5 | NIST CSF 2.0 | OWASP ASVS 5.0 |
|---|---|---|---|
| Worker and imported script integrity | SI-7, SC-18 | PR.PS-06 | V3.4.3, V3.6.1 |
| Cached and stored data protection | SC-28 | PR.DS-10 | V14.3.1, V14.3.2, V14.3.3 |
| Transport and push channel | SC-8, SC-12 | PR.DS-02 | V3.4.3 |
| Inventory of workers and push services | SA-11 | ID.AM-02 | Not specific |
| Testing worker behaviour | SA-11 | ID.RA-01 | Not specific |
Common failure patterns
- Cache everything: a tutorial fetch handler that calls
cache.put()on every response, so account pages and API data sit in Cache Storage for the next user of a shared laptop. - Uploads on the app origin: a file upload feature serving content from the same origin, which lets an XSS register a root-scoped worker from an uploaded file.
- No way back: a broken worker shipped with no kill switch, leaving users on a cached broken app until they clear site data by hand.
- Old caches never deleted: new releases add cache names but the activate handler does not remove old ones, so vulnerable JavaScript remains on disk and can be served again by a later bug.
- Logging push subscriptions: full endpoint URLs written to application logs or analytics, turning a capability URL into something many staff and tools can read.
- VAPID key in the front end repository: the private key committed alongside the public one, which is a secrets leak like any other. See the Secrets Management Guide.
Practitioner checklist
- Register each worker at the narrowest workable scope and review any Service-Worker-Allowed header.
- Add
worker-srcto CSP and serve user uploads from a separate origin. - Set
updateViaCache: 'none', serve the worker withCache-Control: no-cacheand useskipWaiting()for security releases. - Never cache authenticated, personalised, error or
no-storeresponses in shared caches. - Version cache names per release and delete old caches in the activate handler.
- Clear per-user caches, IndexedDB and cookies at logout, using Clear-Site-Data where it fits.
- Keep the manifest on your origin, declare scope and id, and keep identifiers out of start_url.
- Protect subscription registration against CSRF, treat subscriptions as credentials and never log endpoints.
- Store the VAPID private key in a secrets manager and document its rotation.
- Build, exercise and document a kill switch before going live.
Standards and references
- W3C: Service Workers
- W3C: Web Application Manifest
- IETF: RFC 8292 Voluntary Application Server Identification (VAPID) for Web Push
- OWASP: Application Security Verification Standard 5.0
- MDN: Using Service Workers
- MDN: Clear-Site-Data
- MDN: Push API
- NIST: SP 800-53 Rev. 5
Related NHI Mgmt Group resources: Client-Side Security Guide · Content Security Policy Guide · Browser Storage Security Guide · Same-Origin Policy and Browser Security Model Guide