Skip to content

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, and http://localhost (plus *.localhost in most engines), 127.0.0.0/8 and [::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-age ramp before you consider the preload list: preloading requires max-age of at least 31536000, includeSubDomains and preload, 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-Data on 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 and Permissions-Policy where 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:

secure-context-check.js
/**
 * 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://localhost is fine. Every current engine treats localhost and the loopback ranges as potentially trustworthy, so service workers, Cache Storage and push subscriptions work on a local dev server without certificates.
  • *.localhost subdomains (for example http://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:5173 gives you an insecure context: navigator.serviceWorker is undefined. 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's chrome://flags/#unsafely-treat-insecure-origin-as-secure.
  • file:// is a secure context but not a PWA host. Step 5 makes file: pages secure contexts, so crypto.subtle works, but register() rejects because the page, the script and the scope must use http: or https:. Opening index.html from 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:

HSTS header on every HTTPS response
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:

  1. Serve a valid certificate.
  2. Redirect from HTTP to HTTPS on the same host, if you listen on port 80.
  3. Serve all subdomains over HTTPS, including www if a DNS record exists for it.
  4. Serve an HSTS header on the base domain for HTTPS requests with max-age of at least 31536000 seconds (one year), includeSubDomains, and preload.

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

nginx.conf (HTTPS server with redirect and HSTS)
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> (not srcset or <picture>), CSS images such as background-image and border-image, and audio and video loaded through <audio>, <video> and <source>. Browsers rewrite the request to https: 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, srcset images, sendBeacon() and <object>. Browsers block these outright.

Mixed content matters more in a PWA for three reasons:

  1. 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.url for an upgraded <img> already starts with https:, and a blocked request never produces a fetch event at all. But every request the worker initiates is a fetch() from a secure context, and fetch() is blockable content: a hard-coded http:// 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 fails cache.addAll() and therefore the whole install.
  2. The manifest is fetched and parsed separately. Icons, screenshots, shortcuts and share_target.action URLs must resolve to secure URLs. start_url and scope must be same-origin with the manifest's document, so an http:// value there is simply invalid. See the members reference.
  3. 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, when registration.update() is called, and when a functional event such as push or sync fires more than 24 hours after the last check. With the default updateViaCache: "imports" (Chrome 68 and later), the script request bypasses the HTTP cache every time, whatever its Cache-Control says; the old "HTTP cache capped at 24 hours" rule only matters if you opt into updateViaCache: "all".
  • Clear-Site-Data is 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, because caches is 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 .js file, 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 for importScripts(). 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 the Integrity-Policy header, 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: private and no-store in 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-Data on the sign-out response.
  • Broadcast sign-out to other open windows (with BroadcastChannel or 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" and rel="noopener", which in an installed app opens the system browser or an in-app browser tab instead of your window.
  • Keep the manifest scope as 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:

security-headers.mjs
/**
 * 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-age of at least one year; preloading is a deliberate decision.
  • No http: URLs in HTML, CSS, the manifest, precache lists or worker routes.
  • sw.js lives at a stable, same-origin URL, is served as text/javascript with Cache-Control: no-cache, has its own CSP, and is not behind redirects or authentication.
  • Service-Worker-Allowed is only sent on the worker script, if at all.
  • User uploads are served from a separate origin with Content-Disposition: attachment and X-Content-Type-Options: nosniff.
  • The worker does not cache responses marked private or no-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-Data procedure 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 explicit worker-src.
  • Sessions use HttpOnly; Secure; SameSite cookies (prefer __Host-), not tokens in localStorage.
  • 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.

    Service Worker Security

  • Content Security Policy


    worker-src, manifest-src, connect-src and the worker's own policy, strict CSP with nonces or hashes for cached app shells, Trusted Types, reporting, and COOP, COEP and CORP.

    Content Security Policy

  • Permissions


    The Permissions API, prompts and user activation, Permissions Policy, and how installed apps change permission behavior.

    Permissions

  • Privacy & Storage Partitioning


    Storage partitioning, third-party service workers, eviction and clearing, fingerprinting surfaces and privacy-respecting PWA design.

    Privacy & Storage Partitioning

Further reading

On this site

External references