Security and Privacy for Progressive Web Apps¶
A Progressive Web App runs on the same web security model as any site (origins, HTTPS, CORS, Content Security Policy), but it adds pieces that change the stakes: a service worker that keeps running your code after the tab closes, caches that survive deploys, an installed window without a URL bar, and access to push, files, hardware and payments. A bug that would cause one bad page load on a classic site can persist for weeks inside an installed PWA. This page defines the baseline every PWA needs (secure contexts, HTTPS with HSTS, no mixed content), lays out a PWA-specific threat model, and summarizes the security headers you should send. The pages in this section then go deep on each area.
Key takeaways
- Every PWA capability that matters (service workers, Cache Storage, push, Web Share,
crypto.subtle, WebAuthn, the Storage API) is restricted to secure contexts. HTTPS is a hard requirement, andhttp://localhost(plus*.localhostin most engines),127.0.0.0/8and[::1]are the only exceptions intended for development. - A secure context also requires every ancestor frame to be secure. An HTTPS iframe inside an HTTP page is not a secure context.
- Deploy HSTS with a staged
max-ageramp before you consider the preload list: preloading requiresmax-ageof at least31536000,includeSubDomainsandpreload, and removal takes months. - The PWA threat model adds persistence to every classic web attack. An XSS bug can poison Cache Storage or register a rogue worker, and the damage outlives the fix unless you have a kill switch.
- Treat the service worker as the most privileged script on your origin: same-origin only, served with a JavaScript MIME type, never from a CDN, with its own CSP, and with no third-party
importScripts()you have not pinned and audited. - Plan for shared and lost devices: sign-out must clear user-specific caches and IndexedDB, and
Clear-Site-Dataon the sign-out response is the strongest server-side tool. - Send a baseline header set on every response (HSTS, CSP,
X-Content-Type-Options,Referrer-Policy,frame-ancestors) and add COOP, COEP andPermissions-Policywhere they apply.
Why PWAs have a different security profile¶
Classic web security reasoning assumes that a page is short-lived: the browser fetches it, runs it, and throws it away when the user navigates. Fix a vulnerability on the server and the next page load is clean. Progressive Web Apps break that assumption in several ways:
| PWA feature | Security consequence |
|---|---|
| Service worker | Your script intercepts every request in scope, including navigations, and stays registered until it is replaced or unregistered. A compromised worker can serve arbitrary HTML for your origin, strip security headers from responses, and survive a server-side fix. |
| Cache Storage and IndexedDB | Responses and data persist across deploys and browser restarts. Anything a script can write (including an attacker's script during an XSS) can be served later from cache. |
| Offline-first architecture | Pages load without contacting the server, so server-side mitigations (a new CSP, a patched template) do not reach users until the worker updates. |
| Installation and standalone display | The app window has no address bar, so users cannot see the URL or the certificate status. Phishing a user inside an installed app is easier if you let untrusted content navigate your window. |
| Long-lived sessions | Installed apps are expected to stay signed in for months, which makes session tokens valuable and their storage location important. |
| Powerful capabilities | Push, notifications, file handling, protocol handlers, Web Share Target, Bluetooth, USB and payments turn an XSS from "read the page" into "act on the device". |
| Background execution | Push, Background Sync and Periodic Sync can wake your worker without an open window, so compromised code can run when the user is not looking. |
None of this makes PWAs less secure than native apps or classic sites by default. It means that the ordinary defenses (HTTPS, output encoding, CSP, careful dependency management) matter more, and that you need a few PWA-specific ones: a way to kill a bad worker, rules for what a worker may cache, and a clean sign-out path.
Secure contexts¶
The platform concept that gates almost every PWA API is the secure context, defined by the W3C Secure Contexts specification. APIs marked [SecureContext] in Web IDL do not exist at all in insecure contexts: the property is absent, not merely non-functional.
What makes an origin potentially trustworthy¶
The specification's "Is origin potentially trustworthy?" algorithm is short, and its steps explain every edge case you will hit in development:
| Step | Condition | Result |
|---|---|---|
| 1 | Origin is opaque (sandboxed iframe without allow-same-origin, data: document) | Not trustworthy |
| 2 | Scheme is https or wss | Potentially trustworthy |
| 3 | Host is in 127.0.0.0/8 or ::1/128 | Potentially trustworthy |
| 4 | Browser follows the "let localhost be localhost" name-resolution rules, and the host is localhost, localhost., or ends in .localhost / .localhost. | Potentially trustworthy |
| 5 | Scheme is file | Potentially trustworthy |
| 6 | Scheme is one the browser considers authenticated (for example extension schemes) | Potentially trustworthy |
| 7 | Origin was configured as trustworthy by the user or developer (flags, enterprise policy) | Potentially trustworthy |
| 8 | Anything else, including http://192.168.1.20 and http://example.com | Not trustworthy |
A URL is potentially trustworthy if it is about:blank, about:srcdoc, a data: URL, or its origin passes the algorithm above. A document is a secure context only if its own URL is potentially trustworthy and every ancestor document is a secure context too. That ancestor rule is deliberate: the spec notes that cooperative frames could otherwise be abused to bypass restrictions. The practical consequences:
| Document URL | Embedded in | Secure context? |
|---|---|---|
https://app.example.com/ | Top level | Yes |
https://app.example.com/ | https://portal.example.org/ | Yes |
https://app.example.com/ | http://portal.example.org/ | No |
http://app.example.com/ | https://portal.example.org/ | No (and blocked as mixed content anyway) |
http://localhost:5173/ | Top level | Yes |
http://dev.localhost:5173/ | Top level | Yes, in browsers that resolve *.localhost to loopback |
http://192.168.1.20:5173/ | Top level | No |
file:///Users/me/app/index.html | Top level | Yes, but service workers still cannot register (see below) |
Workers inherit from their owner: a dedicated worker created by a secure page is a secure context. Service workers are always secure contexts because only secure contexts can register them, and the worker script itself must come from an http: or https: URL that is same-origin with the registering page.
isSecureContext and feature detection¶
window.isSecureContext and self.isSecureContext (in workers) return the result of the algorithm above. MDN lists it in Chrome 47, Firefox 49 and Safari 11.1. Use it to explain a missing feature to the user rather than failing silently:
/**
* Reports which PWA capabilities are unavailable because the page is not a
* secure context, so support staff can tell "wrong URL" from "old browser".
*/
export function describeSecureContext() {
if (window.isSecureContext) {
return { secure: true, missing: [] };
}
// In an insecure context, [SecureContext] members are simply absent.
const gated = {
serviceWorker: "serviceWorker" in navigator,
cacheStorage: "caches" in window,
subtleCrypto: Boolean(window.crypto && window.crypto.subtle),
storageManager: "storage" in navigator,
webShare: "share" in navigator,
credentials: "credentials" in navigator,
};
return {
secure: false,
missing: Object.entries(gated)
.filter(([, present]) => !present)
.map(([name]) => name),
hint:
location.protocol === "http:"
? "Open the https:// URL, or use http://localhost during development."
: "An ancestor frame is not a secure context.",
};
}
APIs that PWAs commonly rely on and that require a secure context include navigator.serviceWorker, caches (Cache Storage), crypto.subtle, navigator.storage (quota and persistence), navigator.share, the async Clipboard API, navigator.mediaDevices.getUserMedia(), geolocation, WebAuthn and passkeys through navigator.credentials, the Payment Request API, the Badging API, the File System Access pickers, Web Bluetooth and WebUSB. Push and notifications depend on a service worker registration, so they inherit the requirement.
localhost, LAN addresses and file:// during development¶
http://localhostis fine. Every current engine treatslocalhostand the loopback ranges as potentially trustworthy, so service workers, Cache Storage and push subscriptions work on a local dev server without certificates.*.localhostsubdomains (for examplehttp://api.localhost:8080) are trustworthy under step 4 when the browser guarantees they resolve to loopback. They are useful for testing multi-origin setups, such as a separate API origin or a separate user-content origin, without certificates.- A LAN IP is not. Testing on a phone against
http://192.168.1.20:5173gives you an insecure context:navigator.serviceWorkerisundefined. Use a tunnel with a real HTTPS hostname, a locally trusted certificate (for example from mkcert) installed on the device, or, on a test device only, Chromium'schrome://flags/#unsafely-treat-insecure-origin-as-secure. file://is a secure context but not a PWA host. Step 5 makesfile:pages secure contexts, socrypto.subtleworks, butregister()rejects because the page, the script and the scope must usehttp:orhttps:. Openingindex.htmlfrom disk never gives you a service worker. The registration and scope page lists the exact errors.
Certificate errors are never 'good enough'
Clicking through a certificate interstitial does not make a page usable as a PWA. Chromium refuses to fetch the service worker script over a connection with a certificate error, so registration fails for real users while it may succeed in automated tests launched with --ignore-certificate-errors. Fix the certificate; do not ship instructions telling users to bypass warnings.
HTTPS setup for PWAs¶
HTTPS is table stakes, but a PWA has a few specific requirements beyond "the home page loads over TLS": the service worker script, the manifest, every icon and every precached URL must load over HTTPS without redirects that cross origins, and the worker script may not be behind a redirect at all.
Certificates and automation¶
Use an ACME-based certificate authority (Let's Encrypt, or the managed certificates of your CDN or platform) and automate renewal. Automation is no longer optional: the CA/Browser Forum's Ballot SC-081 phases the maximum lifetime of publicly trusted TLS certificates down from 398 days to 47 days between March 2026 and March 2029: 200 days for certificates issued from 15 March 2026, 100 days from 15 March 2027 and 47 days from 15 March 2029. The same ballot cuts the reuse period for domain validation data to 10 days by 2029, so manual renewal with a cached validation stops being practical. An expired certificate on a PWA origin is worse than on a classic site: every update check for sw.js fails, so installed users stay on whatever version they last received, and first-time visitors cannot register at all.
Monitor certificate expiry for every hostname the app touches: the app origin, the API origin, the push endpoint of your own backend, image CDNs and any origin in connect-src.
Redirect everything to HTTPS, then add HSTS¶
The first request a user types (example.com in the address bar, or an old http:// bookmark) may still go out over plain HTTP. Redirect it with a 301 or 308 to the same host on HTTPS, then tell the browser never to use HTTP again with the Strict-Transport-Security header defined in RFC 6797:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
| Directive | Meaning |
|---|---|
max-age=<seconds> | How long the browser remembers that the host is HTTPS-only. Each HTTPS response with the header resets the timer. max-age=0 removes the entry. |
includeSubDomains | Applies the policy to every subdomain. Required for preloading, and it breaks any subdomain that still serves only HTTP (internal tools, legacy marketing sites). |
preload | Signals consent to inclusion in browsers' built-in preload lists. It has no effect on its own. |
The browser ignores HSTS headers received over plain HTTP (an attacker could otherwise inject them), which is why the redirect must come first. Once a host is known HSTS, the browser rewrites http:// URLs to https:// internally before sending anything, and it turns certificate errors into hard failures with no click-through.
HSTS preload¶
Without preloading, the very first visit is still vulnerable to an SSL-stripping attacker. The HSTS preload list, which Chromium maintains and other browsers consume, closes that gap by shipping your domain in the browser binary. The submission requirements at hstspreload.org are:
- Serve a valid certificate.
- Redirect from HTTP to HTTPS on the same host, if you listen on port 80.
- Serve all subdomains over HTTPS, including
wwwif a DNS record exists for it. - Serve an HSTS header on the base domain for HTTPS requests with
max-ageof at least31536000seconds (one year),includeSubDomains, andpreload.
The site recommends a staged rollout, waiting the full max-age of each stage and watching for breakage before moving on:
| Stage | Header | Wait |
|---|---|---|
| 1 | max-age=300; includeSubDomains | 5 minutes |
| 2 | max-age=604800; includeSubDomains | 1 week |
| 3 | max-age=2592000; includeSubDomains | 1 month |
| 4 | max-age=63072000; includeSubDomains; preload | Submit to the preload list |
Preloading is effectively permanent: the list warns that inclusion "cannot easily be undone" and that removals take months to reach users through browser updates. Do not put preload in shared configuration templates, and do not preload a registrable domain whose subdomains you do not fully control.
HTTPS by default in browsers¶
Browsers are moving to HTTPS-first behavior, which reduces but does not replace HSTS. Google announced that Chrome enables "Always Use Secure Connections" for public sites for Enhanced Safe Browsing users in Chrome 147 (April 2026) and for all users in Chrome 154 (October 2026). With that setting, Chrome tries HTTPS first and shows a warning before loading a public site over HTTP. Private addresses such as 192.168.0.1 and intranet hosts are excluded. HSTS still matters because it removes the interstitial path, turns certificate errors into hard failures, and covers browsers without HTTPS-first defaults.
Server configuration example¶
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# 301 to the same host on HTTPS; HSTS is never sent over HTTP.
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# "always" also adds the header to 4xx/5xx responses.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
root /srv/app/dist;
location = /sw.js {
# nginx drops inherited add_header directives in a block that
# declares its own, so repeat the security headers here.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Cache-Control "no-cache" always;
types { text/javascript js; }
}
}
The nginx inheritance rule in that comment is a frequent source of missing headers: any add_header in a location block replaces, rather than extends, the headers from the enclosing block. Verify the final headers with curl -sI for the HTML, sw.js, the manifest and a static asset.
Mixed content¶
Mixed content is any http: subresource requested by an HTTPS document. The Mixed Content specification splits it into two categories:
- Upgradable content: images loaded through
<img src>(notsrcsetor<picture>), CSS images such asbackground-imageandborder-image, and audio and video loaded through<audio>,<video>and<source>. Browsers rewrite the request tohttps:and load it if the host supports HTTPS. MDN notes that requests whose host is an IP address are blocked rather than upgraded. Firefox 127, for example, started upgrading audio, video and image requests and blocking the rest. - Blockable content: everything else, including scripts, stylesheets, iframes,
fetch(),XMLHttpRequest, fonts,srcsetimages,sendBeacon()and<object>. Browsers block these outright.
Mixed content matters more in a PWA for three reasons:
- URLs the worker builds itself are never upgraded. Upgrading and blocking happen in Fetch's main fetch, before a request is handed to the service worker, so
event.request.urlfor an upgraded<img>already starts withhttps:, and a blocked request never produces afetchevent at all. But every request the worker initiates is afetch()from a secure context, andfetch()is blockable content: a hard-codedhttp://URL in a precache list, a runtime route, or a URL read from JSON is blocked inside the worker even where the equivalent<img src>in the page would have been silently upgraded. A precache that contains one such URL failscache.addAll()and therefore the whole install. - The manifest is fetched and parsed separately. Icons, screenshots, shortcuts and
share_target.actionURLs must resolve to secure URLs.start_urlandscopemust be same-origin with the manifest's document, so anhttp://value there is simply invalid. See the members reference. - Cached content outlives the fix. A page cached with an
http://script reference keeps failing offline until the worker replaces it.
Content-Security-Policy: upgrade-insecure-requests asks the browser to upgrade all of a document's http: subresource URLs (and same-origin navigations) before fetching. It is a useful safety net during an HTTPS migration, but it does not replace fixing the URLs, and it only applies to the documents (and workers) whose policy includes it. The Content Security Policy page covers it alongside the other directives.
The PWA threat model¶
A threat model starts from assets and attackers. For a typical PWA the assets are the user's session (cookies, tokens, passkeys), the user's data (in IndexedDB, Cache Storage, OPFS and on the server), the integrity of the code the user runs, and the device capabilities the user has granted. The attackers range from a network adversary on public Wi-Fi to a script injected through a vulnerable dependency.
flowchart TD
A["Attacker"] --> N["Network position"]
A --> X["XSS or HTML injection"]
A --> S["Compromised dependency or CDN"]
A --> D["Physical access to device"]
N -->|"No HSTS, first HTTP request"| X
S --> X
X --> R["Register rogue service worker"]
X --> P["Poison Cache Storage"]
X --> T["Read tokens in JS-visible storage"]
X --> C["Abuse granted capabilities"]
R --> Q["Persistent control of origin"]
P --> Q
D --> U["Read cached private data"]
T --> H["Session hijack"] The table maps each threat to the PWA-specific amplifier and the defenses that apply. Each row links to the page that covers it in depth.
| Threat | PWA-specific amplifier | Primary defenses | Details |
|---|---|---|---|
| Network interception | First visit over HTTP, captive portals, stale HTTP caches feeding the precache | HTTPS everywhere, HSTS and preload, no mixed content | This page |
| XSS | Attacker can write to Cache Storage and IndexedDB, register a worker from any same-origin JavaScript URL, and use every granted permission | Output encoding, strict CSP, Trusted Types, verifying cached app shell, kill switch | Service Worker Security, Content Security Policy |
| Rogue or compromised service worker | Survives server fixes, controls every navigation, can strip security headers | Same-origin script with JS MIME type, Service-Worker request header filtering, no global Service-Worker-Allowed, Clear-Site-Data kill switch | Service Worker Security |
| Supply chain | importScripts() from a CDN runs with full worker privileges and is re-fetched on update checks | Self-host and pin, bundle the worker, SRI for page scripts, Integrity-Policy, lockfiles and review | Service Worker Security |
| Token theft | Installed apps keep long sessions; tokens in localStorage or IndexedDB are readable by any XSS | HttpOnly, Secure, SameSite cookies with __Host- prefix, short-lived access tokens, passkeys, device-bound sessions | Authentication & Passkeys |
| Shared or lost devices | Private responses in Cache Storage, drafts in IndexedDB, push subscriptions tied to the previous user | Do not cache private responses by default, per-user cache namespaces, sign-out cleanup, Clear-Site-Data | Service Worker Security, Privacy |
| Phishing in standalone windows | No visible URL bar | Keep untrusted content out of your scope, open external links in the browser, use scope correctly | Display Modes |
| Capability abuse | Push, files, hardware and payments are available after a single grant | Request permissions in context, Permissions Policy, least privilege | Permissions |
| Cross-site leaks and tracking | Storage and service workers in third-party contexts | Storage partitioning, COOP, CORP | Privacy & Storage Partitioning |
Service worker persistence¶
The service worker specification describes the risk plainly: service workers "create the opportunity for a bad actor to turn a bad day into a bad eternity." Once a worker is registered, it stays registered until one of the following happens: the browser fetches a byte-different script at the same URL and installs it, script code calls registration.unregister(), the origin's storage is cleared (by the user, by storage pressure eviction, or by a Clear-Site-Data: "storage" response), or the user removes the site data manually.
Two platform guarantees keep this recoverable, and you must preserve both:
- The update request for the worker script always bypasses the worker. Its service-workers mode is
none, so a broken or malicious worker cannot intercept its own update. Browsers run the update check on every navigation into scope, whenregistration.update()is called, and when a functional event such aspushorsyncfires more than 24 hours after the last check. With the defaultupdateViaCache: "imports"(Chrome 68 and later), the script request bypasses the HTTP cache every time, whatever itsCache-Controlsays; the old "HTTP cache capped at 24 hours" rule only matters if you opt intoupdateViaCache: "all". Clear-Site-Datais honored on that update response, because it is a network response. The Clear Site Data spec's own "kill switch" example relies on this.
The implication for your deployment: keep the worker at one stable URL forever, make sure that URL always returns JavaScript you control, and rehearse the kill-switch procedure before you need it. The Service Worker Security page has the full procedure and code, and Updating Service Workers covers the update algorithm.
XSS in an installed app¶
Cross-site scripting in a PWA is not just "the attacker runs script in one page." With script execution on your origin, an attacker can:
- write any response into Cache Storage under any URL key, including
/index.html, becausecachesis shared by the page and the worker; - register a service worker from any same-origin URL that returns JavaScript with a JavaScript MIME type (a JSONP endpoint, an uploaded
.jsfile, a debug route); - read IndexedDB, OPFS and
localStorage, including any tokens stored there; - use every permission the user has granted to the origin: show notifications, read files through handles saved in IndexedDB, send data to paired Bluetooth or USB devices;
- subscribe the user to push under a different application server key if the old subscription is unsubscribed.
The defenses are the classic ones, applied with more rigor: context-aware output encoding, a strict nonce- or hash-based CSP, Trusted Types to close DOM sinks, and no inline event handlers. See Content Security Policy. The PWA-specific additions are recovery tools: verify the integrity of cached app-shell responses before serving them, and be ready to wipe caches with a kill-switch worker or Clear-Site-Data.
Supply chain¶
Every package in your front-end bundle runs with your origin's full privileges, and the service worker is the most privileged context of all. Supply-chain risks specific to PWAs:
importScripts()from third-party origins. Cross-origin classic imports are allowed, the imported code runs inside your worker, and the update algorithm re-fetches imported scripts and byte-compares them. A compromised CDN file becomes a compromised worker at your users' next update check. There is no integrity attribute forimportScripts(). Bundle the worker at build time instead.- Build plugins that generate the worker. Workbox, the Vite PWA plugin and framework integrations write code into your worker. Pin their versions and review their output when you upgrade. See Workbox Fundamentals.
- Third-party page scripts. Analytics, A/B testing and chat widgets can call
navigator.serviceWorker.register()and write to your caches. Load them with Subresource Integrity where the vendor publishes versioned files, and consider theIntegrity-Policyheader, which requires integrity metadata on script requests and is listed by MDN in Chrome 138, Safari 26 and (partially, without report delivery) Firefox 145.
Token theft and session storage¶
Installed PWAs tend to keep users signed in for a long time. Where you keep the session determines what an XSS can steal:
| Storage location | Readable by page XSS | Sent automatically | Survives worker termination | Notes |
|---|---|---|---|---|
HttpOnly; Secure; SameSite cookie, ideally __Host- prefixed | No | Yes, to your origin | Yes | Best default. Needs CSRF defenses for state-changing requests. |
localStorage / sessionStorage | Yes | No | Yes | Unavailable in workers. Any XSS exfiltrates the token. |
| IndexedDB | Yes | No | Yes | Readable by the worker and by any XSS on the page. |
| Service worker global variable | Not directly | No | No | The worker is terminated when idle, so the token is lost and must be re-fetched. XSS can still use the worker as a proxy for authenticated requests. |
| Device-bound session (DBSC) | No | Yes | Yes | Device Bound Session Credentials binds short-lived cookies to a key held in secure hardware. Chromium-only: MDN lists it in Chrome 145 on Windows, extended to macOS in Chrome 147, and not in Firefox or Safari. |
No storage choice stops an attacker who can run script in your origin from using the session while the page is open. What the right choice prevents is exfiltration: a stolen HttpOnly cookie is not possible from script, and a device-bound cookie is useless on another machine. Passkeys remove the password from the equation entirely; see Authentication & Passkeys.
Shared and lost devices¶
Assume that someone else will open your app on the same device after your user signs out, and that a lost phone will be examined offline. Concretely:
- Do not cache responses that contain user-specific data unless offline access to them is a feature. If it is, store them under a per-user namespace so you can delete exactly that data.
- Respect
Cache-Control: privateandno-storein your worker's runtime caching rules. Cache Storage ignores those headers unless your code checks them. - On sign-out, delete user caches and IndexedDB databases from the page, tell the worker to drop any in-memory state, unsubscribe from push if notifications are per-user, and send
Clear-Site-Dataon the sign-out response. - Broadcast sign-out to other open windows (with
BroadcastChannelor through the worker) so a second window does not keep showing private data.
The Service Worker Security page includes a complete sign-out cleanup implementation, and Privacy & Storage Partitioning covers what browsers clear on their own.
Phishing and the missing URL bar¶
In standalone and minimal-ui display modes the browser hides or minimizes the address bar, so users cannot verify where content comes from. Browsers compensate: when a standalone app navigates outside its manifest scope, Chromium and Safari show the out-of-scope URL in a toolbar or in-app browser view. Your job is to keep untrusted content from rendering as if it were your app:
- Never render user-supplied HTML in your origin without sanitization, and host user uploads on a separate origin.
- Open third-party links with
target="_blank"andrel="noopener", which in an installed app opens the system browser or an in-app browser tab instead of your window. - Keep the manifest
scopeas narrow as your app, so that other applications on the same origin do not appear inside your standalone window. See App Identity & Updates. - Treat payment and sign-in flows with extra care; users cannot see the URL to spot a lookalike page.
Security headers overview¶
The table lists the response headers that matter for a PWA, what each one protects, and where to send it. The Content Security Policy page covers CSP, COOP, COEP and CORP in depth. Browser support notes come from MDN's compatibility data. Support data as of September 2026; check MDN and caniuse for live data.
| Header | Recommended value | Protects against | Send on | PWA notes |
|---|---|---|---|---|
Strict-Transport-Security | max-age=63072000; includeSubDomains (+ preload once ready) | SSL stripping, downgrade | Every HTTPS response | Makes certificate errors non-bypassable, which also protects worker update checks. |
Content-Security-Policy | Strict nonce- or hash-based script-src, object-src 'none', base-uri 'none', explicit worker-src, manifest-src, connect-src, frame-ancestors | XSS, clickjacking, data exfiltration | HTML documents and sw.js | The worker is governed by the CSP on its own script response, not the page's. |
X-Content-Type-Options | nosniff | MIME confusion (uploaded files executed as script or style) | Every response | Service worker and import script MIME checks are strict regardless, but nosniff protects other script loads. |
Referrer-Policy | strict-origin-when-cross-origin (browser default) or stricter | URL leaks to third parties | HTML | Share targets and deep links often put data in URLs. |
Permissions-Policy | Disable features you do not use, for example camera=(), microphone=(), geolocation=() | Capability abuse by embedded content or injected script | HTML | Chromium-only per MDN; Firefox and Safari ignore the header. See Permissions. |
Cross-Origin-Opener-Policy | same-origin or same-origin-allow-popups | Cross-window attacks, XS-Leaks, Spectre | HTML | Breaks window.opener for OAuth and payment popups unless you use same-origin-allow-popups. |
Cross-Origin-Resource-Policy | same-origin or same-site on private resources | Cross-origin reads via no-cors embeds | Every response | Controls whether your responses can be embedded under COEP. |
Cross-Origin-Embedder-Policy | require-corp or credentialless, only if you need cross-origin isolation | Enables SharedArrayBuffer and precise timers safely | HTML and sw.js | Opaque responses from Cache Storage are subject to CORP checks. |
frame-ancestors (CSP) / X-Frame-Options | frame-ancestors 'none' or a list; X-Frame-Options: DENY for old browsers | Clickjacking | HTML | frame-ancestors is ignored in <meta> CSP; send it as a header. |
Integrity-Policy | blocked-destinations=(script) | Scripts loaded without SRI | HTML | Chrome 138, Safari 26, Firefox 145 (partial) per MDN. Start with Integrity-Policy-Report-Only. |
Clear-Site-Data | "cache", "cookies", "storage" | Leftover data after sign-out, compromised clients | Sign-out response, emergency sw.js response | "storage" also unregisters service workers. Chrome 61, Firefox 63, Safari 17 per MDN. |
Reporting-Endpoints | csp="https://example.com/reports/csp" | (Delivery of reports) | HTML and sw.js | Required for report-to in CSP, COOP, COEP and Integrity-Policy. |
Cache-Control | no-store or private, no-cache on personalized responses | Private data in shared caches | Personalized responses | Your worker must honor it; Cache Storage does not. See HTTP Caching & Service Workers. |
A baseline for most PWAs, as a Node.js middleware:
/**
* Express/Connect middleware that sets a baseline of security headers.
* CSP is generated per response because it carries a fresh nonce.
*/
import crypto from "node:crypto";
export function securityHeaders({ reportEndpoint = "/reports/csp" } = {}) {
return function (req, res, next) {
// 128 bits of randomness per response, base64-encoded.
const nonce = crypto.randomBytes(16).toString("base64");
res.locals.cspNonce = nonce;
res.setHeader("Strict-Transport-Security", "max-age=63072000; includeSubDomains");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader("Cross-Origin-Opener-Policy", "same-origin-allow-popups");
res.setHeader("Cross-Origin-Resource-Policy", "same-site");
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), usb=(), serial=()");
res.setHeader("Reporting-Endpoints", `csp="${reportEndpoint}"`);
res.setHeader(
"Content-Security-Policy",
[
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
"object-src 'none'",
"base-uri 'none'",
"worker-src 'self'",
"manifest-src 'self'",
"connect-src 'self' https://api.example.com",
"img-src 'self' data: https://images.example-cdn.com",
"frame-ancestors 'none'",
"form-action 'self'",
"report-to csp",
].join("; "),
);
next();
};
}
A nonce-based policy like this one only works for HTML rendered per request. If your service worker caches and replays an app shell, use a hash-based policy instead. The Content Security Policy page explains why and gives complete policies for both cases.
A minimum security baseline¶
Use this list as a gate before launch. The Production Checklist covers the rest of a PWA launch.
- Every hostname serves valid HTTPS with automated renewal; HTTP redirects to HTTPS on the same host.
- HSTS is deployed with a
max-ageof at least one year; preloading is a deliberate decision. - No
http:URLs in HTML, CSS, the manifest, precache lists or worker routes. -
sw.jslives at a stable, same-origin URL, is served astext/javascriptwithCache-Control: no-cache, has its own CSP, and is not behind redirects or authentication. -
Service-Worker-Allowedis only sent on the worker script, if at all. - User uploads are served from a separate origin with
Content-Disposition: attachmentandX-Content-Type-Options: nosniff. - The worker does not cache responses marked
privateorno-store, or personalized API responses, unless offline access to them is a feature with per-user cleanup. - Sign-out deletes user caches and databases and sends
Clear-Site-Data. - A kill-switch worker and a
Clear-Site-Dataprocedure are documented and rehearsed. - CSP is strict (nonces or hashes,
'strict-dynamic',object-src 'none',base-uri 'none'), reports to an endpoint, and has an explicitworker-src. - Sessions use
HttpOnly; Secure; SameSitecookies (prefer__Host-), not tokens inlocalStorage. - Permissions are requested in context and unused features are disabled with Permissions Policy.
Pages in this section¶
-
Service Worker Security
Same-origin and scope rules, XSS-to-worker persistence,
importScripts(), cache poisoning, private responses and sign-out cleanup, kill switches, CORS, opaque and redirected responses, and an audit checklist. -
Content Security Policy
worker-src,manifest-src,connect-srcand the worker's own policy, strict CSP with nonces or hashes for cached app shells, Trusted Types, reporting, and COOP, COEP and CORP. -
Permissions
The Permissions API, prompts and user activation, Permissions Policy, and how installed apps change permission behavior.
-
Privacy & Storage Partitioning
Storage partitioning, third-party service workers, eviction and clearing, fingerprinting surfaces and privacy-respecting PWA design.
Further reading¶
On this site
- Registration & Scope: serving requirements for the worker script and registration errors
- Updating Service Workers: the update check, kill switches and rollback
- Cache Storage API: what the cache stores, including opaque responses
- HTTP Caching & Service Workers:
Cache-Controlfor the worker and for private data - Authentication & Passkeys: session and credential handling in PWAs
- Isolated Web Apps: signed bundles with a stricter security model
- Production Checklist
External references
- Secure Contexts specification (W3C)
- Secure contexts (MDN)
- Mixed content (MDN) and the Mixed Content specification
- RFC 6797: HTTP Strict Transport Security and the HSTS preload list
- Service Workers specification, Security Considerations
- Clear Site Data specification
- OWASP Secure Headers Project
- OWASP HTML5 Security Cheat Sheet