HSTS and service workers overlap in ways that bite teams during deployment, local testing, and incident response.
The short version:
- HSTS tells the browser to always use HTTPS for a host.
- Service workers only work in secure contexts, with a few localhost exceptions.
- If you get your HTTPS and redirect behavior wrong, service worker registration gets flaky fast.
- If you get HSTS wrong, rollback gets painful because browsers cache the policy.
I’ve seen teams debug “random” service worker failures that were really bad TLS, mixed hostnames, or a stale HSTS policy. This guide is the practical version.
What HSTS actually changes for service workers
HSTS is the Strict-Transport-Security response header:
Strict-Transport-Security: max-age=31536000; includeSubDomains
When a browser sees that over HTTPS, it remembers: “for this host, only use HTTPS from now on.”
That matters for service workers because registration requires a secure context. If the browser upgrades requests to HTTPS before the network even happens, you avoid some ugly edge cases where:
- a user types
http://example.com - your app redirects to HTTPS
- some assets or registration scripts still reference
http://... - registration fails or becomes inconsistent
With HSTS in place, the browser upgrades the navigation before making the insecure request. That reduces one whole class of mistakes.
The baseline setup
If you serve a service worker, your site should usually send HSTS on all HTTPS responses for the main host.
Typical production header:
Strict-Transport-Security: max-age=31536000; includeSubDomains
If you want preload eligibility, you also need:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Official docs for HSTS are here:
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
- https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
- https://w3c.github.io/webappsec-secure-contexts/
If you want a quick check of your deployed header set, run a scan at headertest.com.
Copy-paste server configs
Nginx
Use always so error pages also send the header.
server {
listen 443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/ssl/example/fullchain.pem;
ssl_certificate_key /etc/ssl/example/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
root /var/www/app;
index index.html;
location / {
try_files $uri /index.html;
}
location = /sw.js {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
}
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
Apache
<VirtualHost *:443>
ServerName example.com
ServerAlias www.example.com
SSLEngine on
SSLCertificateFile /path/fullchain.pem
SSLCertificateKeyFile /path/privkey.pem
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
DocumentRoot /var/www/app
</VirtualHost>
<VirtualHost *:80>
ServerName example.com
ServerAlias www.example.com
Redirect permanent / https://example.com/
</VirtualHost>
Express
This is the simplest safe version:
import express from 'express';
const app = express();
app.use((req, res, next) => {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
next();
});
app.use(express.static('public'));
app.get('/sw.js', (req, res) => {
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(new URL('./public/sw.js', import.meta.url).pathname);
});
app.listen(3000);
If you’re behind a proxy or load balancer, make sure TLS actually terminates correctly upstream. HSTS does nothing if your cert chain is broken.
Registering a service worker safely
Basic registration:
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('SW registered', reg.scope);
} catch (err) {
console.error('SW registration failed', err);
}
});
}
</script>
And a minimal sw.js:
self.addEventListener('install', event => {
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', event => {
event.respondWith(fetch(event.request));
});
That’s enough to prove the secure-context path works.
The hostname trap
Service workers are scoped to an origin. HSTS is also host-based.
That means these are all meaningfully different:
https://example.comhttps://www.example.comhttps://app.example.com
A service worker registered on www.example.com does not control example.com. HSTS with includeSubDomains affects subdomains, but it does not merge origins.
This bites migrations. Example:
- old app lived on
www.example.com - new app lives on
example.com - users still have a stale worker on
www - your redirects and caches behave differently than expected
Pick a canonical host and be ruthless about redirecting everything else to it.
Example Nginx canonical redirect:
server {
listen 443 ssl http2;
server_name www.example.com;
ssl_certificate /etc/ssl/example/fullchain.pem;
ssl_certificate_key /etc/ssl/example/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
return 301 https://example.com$request_uri;
}
Don’t cache sw.js aggressively
This isn’t strictly an HSTS issue, but the two often get deployed together and then people wonder why updates don’t land.
The browser checks for service worker updates by requesting the script again. If you cache sw.js for a year, you make updates annoying.
Good header for sw.js:
Cache-Control: no-cache
For hashed static assets, cache hard. For the worker script itself, don’t.
Express example:
app.get('/sw.js', (req, res) => {
res.setHeader('Cache-Control', 'no-cache');
res.type('application/javascript');
res.sendFile('/absolute/path/to/public/sw.js');
});
Local development with HSTS
This is where people accidentally make their own life miserable.
A few rules:
localhostis treated specially by browsers for secure-context purposes.- Don’t set HSTS on random dev domains unless you really mean it.
- Don’t preload anything until you are completely sure.
If you set HSTS on dev.example.test in a real browser profile, that browser will remember it. Then later, when your local TLS setup breaks, the browser refuses to connect and gives you no bypass if the cert is invalid enough. Now you’re debugging browser state instead of your app.
For local work, I prefer one of these:
http://localhostfor basic service worker testing where supported- proper HTTPS on a dedicated dev hostname with a trusted local CA
- separate browser profiles for testing HSTS behavior
If you need to clear an HSTS policy during testing, you usually have to remove the browser’s stored site security state. That process is browser-specific and annoying. Better to avoid poisoning your main profile.
Preload and service workers
HSTS preload is great for real production sites with stable HTTPS everywhere. It’s bad for half-finished migrations.
With preload, browsers effectively hardcode HTTPS-only behavior for your domain. That’s fantastic when you’re done. It’s brutal when some forgotten subdomain still serves broken TLS or an old asset path.
Service worker angle:
- your app shell may work
- some old worker-controlled route on a subdomain may fail hard
- rollback is slow because preload removal is not instant
Use preload only when:
- every subdomain that matters supports HTTPS correctly
- redirects are clean
- certificate management is boring and automated
- you’re not still arguing about canonical hostnames
Mixed content still matters
HSTS upgrades top-level navigation for known hosts. It does not magically excuse sloppy asset URLs everywhere.
Bad:
navigator.serviceWorker.register('http://example.com/sw.js');
Good:
navigator.serviceWorker.register('/sw.js');
Bad:
<script src="http://example.com/app.js"></script>
Good:
<script src="/app.js"></script>
Use relative or explicit HTTPS URLs. Better yet, use origin-relative paths for your own assets.
Common failure patterns
1. HTTP redirect works, but service worker still fails
Usually one of:
- invalid TLS certificate
- wrong hostname
- registration script loaded from a different origin
sw.jsserved with a bad content type or blocked path- page is not actually a secure context
2. HSTS enabled, now a subdomain is broken
Classic includeSubDomains problem. Someone enabled it before all subdomains were HTTPS-ready.
3. Worker won’t update
Usually caching on sw.js, not HSTS.
4. Rollback is painful
That’s normal with HSTS. Browsers cache the policy until max-age expires unless you replace it with:
Strict-Transport-Security: max-age=0
That only works if users can still reach the site over valid HTTPS and receive the new header. If certs are broken, you’re stuck.
A sane production checklist
Use this before shipping:
- Serve the app and
sw.jsover valid HTTPS - Redirect all HTTP to HTTPS
- Send
Strict-Transport-Securityon HTTPS responses - Pick one canonical hostname
- Use
includeSubDomainsonly when you mean it - Don’t cache
sw.jsaggressively - Use origin-relative URLs for worker registration and assets
- Test with a fresh browser profile
- Verify headers with browser devtools and a scanner like headertest.com
My default recommendation for most production apps with service workers is:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Then add preload only after your domain estate is boring, consistent, and fully HTTPS-clean. Boring is what you want here. Boring means your service worker registration keeps working, your redirects make sense, and nobody is stuck in an HSTS-induced recovery mess on release day.