If you use WebSockets in a browser app, HSTS absolutely matters. A lot of teams set Strict-Transport-Security for normal page loads and APIs, then forget that their frontend also opens ws:// or wss:// connections. That gap creates weird mixed transport behavior at best and a downgrade risk at worst.
The short version: modern browsers generally apply HSTS to WebSocket connections too. If a host is known to be HSTS, a ws:// URL to that host is treated like wss:// before the network request goes out. That’s good news, but there are edge cases and deployment mistakes that still break things.
The rule that matters
HSTS tells the browser:
- never use plain HTTP for this host for a period of time
- rewrite insecure requests to HTTPS before sending them
- optionally apply the rule to subdomains with
includeSubDomains
For WebSockets, the browser uses the same idea:
ws://example.com/socketbecomeswss://example.com/socketifexample.comis HSTS-known- if the certificate is invalid, the connection fails
- users do not get a click-through bypass like they might with a normal bad TLS page in some flows
That means HSTS helps protect WebSocket handshakes from insecure transport, because the handshake starts as an HTTP request upgraded over either TCP+TLS (wss) or plain TCP (ws).
What HSTS does not do
HSTS does not magically secure a WebSocket endpoint that only exists on port 80.
If your frontend does this:
const socket = new WebSocket("ws://app.example.com/realtime");
and app.example.com is under HSTS, the browser will try:
wss://app.example.com/realtime
If your reverse proxy or app server is not actually serving secure WebSockets there, the connection just fails.
That’s a common production bug: “we enabled HSTS and now the app’s live updates stopped working.”
Browser behavior you should assume
For browser-based clients, assume all of this is true:
- HSTS applies per host.
- If
includeSubDomainsis set on the parent domain, it applies to WebSocket hosts under it. ws://can be upgraded towss://by HSTS before the request leaves the browser.- Non-browser WebSocket clients usually do not implement HSTS.
That fourth point catches people. Your JavaScript app in Chrome may auto-upgrade ws://, but your backend worker using a generic WebSocket library probably won’t. HSTS is mostly a browser-side protection.
The safest approach
Don’t rely on HSTS to rescue bad WebSocket URLs. Just use wss:// explicitly.
Good:
const socket = new WebSocket("wss://app.example.com/realtime");
Bad:
const socket = new WebSocket("ws://app.example.com/realtime");
Even if HSTS will upgrade it, you’re baking in an unnecessary dependency on browser state. First visit, preload status, subdomain coverage, and local HSTS cache all affect what happens.
My rule: if the page is HTTPS, every WebSocket URL should already be wss:// unless you’re doing local development.
HSTS header you should actually deploy
A solid baseline:
Strict-Transport-Security: max-age=31536000; includeSubDomains
If you’re fully confident across the whole domain tree and want preload eligibility:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Be careful with includeSubDomains. If you have legacy WebSocket services on random subdomains that still only support insecure ws://, this will break them in browsers.
Nginx config for HTTPS and secure WebSockets
This is a copy-paste baseline for an app behind Nginx.
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location /realtime {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
}
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
A few things I would not skip:
alwayson the HSTS header- explicit HTTP to HTTPS redirect
- correct
UpgradeandConnectionheaders - TLS termination on the same host the browser connects to
Apache config example
If you’re on Apache with mod_proxy_wstunnel or modern proxy handling:
<VirtualHost *:443>
ServerName app.example.com
SSLEngine on
SSLCertificateFile /path/to/fullchain.pem
SSLCertificateKeyFile /path/to/privkey.pem
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
ProxyPreserveHost On
ProxyPass /realtime ws://127.0.0.1:8080/realtime
ProxyPassReverse /realtime ws://127.0.0.1:8080/realtime
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
</VirtualHost>
<VirtualHost *:80>
ServerName app.example.com
Redirect permanent / https://app.example.com/
</VirtualHost>
Express example behind TLS terminator
Your Node app does not set up HSTS for the WebSocket transport itself if TLS is terminated upstream, but it still helps to set HSTS on normal HTTPS responses.
import express from "express";
import helmet from "helmet";
import { WebSocketServer } from "ws";
import http from "http";
const app = express();
app.use(
helmet.hsts({
maxAge: 31536000,
includeSubDomains: true,
preload: false,
})
);
app.get("/", (req, res) => {
res.send("OK");
});
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: "/realtime" });
wss.on("connection", (ws) => {
ws.send("connected");
});
server.listen(8080, () => {
console.log("App listening on :8080 behind reverse proxy");
});
In production, put this behind Nginx or another TLS terminator and expose it as wss://app.example.com/realtime.
Client-side patterns that avoid pain
Build WebSocket URLs from the page scheme.
const wsScheme = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${wsScheme}://${window.location.host}/realtime`);
That works well for apps that run both locally and in production.
If your WebSocket host is different from your page host:
const socket = new WebSocket("wss://socket.example.com/realtime");
Be explicit. Cross-host setups are exactly where people accidentally assume HSTS on www.example.com covers socket.example.com when it doesn’t, unless includeSubDomains is already set on a parent domain and already cached by the browser.
Common failure modes
1. HSTS on the website, but not on the socket host
Example:
- page loads from
https://www.example.com - WebSocket connects to
ws://socket.example.com/feed
If only www.example.com is HSTS, that does nothing for socket.example.com.
2. includeSubDomains breaks old environments
You enable:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Then an old dashboard at ops.example.com still uses insecure ws://. Browsers start forcing wss:// and the app dies.
3. Certificate mismatch on the socket endpoint
HSTS upgrade to wss:// means the certificate has to be valid for that exact host. No exceptions.
4. Testing with a browser that already cached HSTS
You fix a bug, reload, and still see forced secure behavior because the browser remembers the old HSTS policy. That can make debugging really annoying.
How to test it
Check your response headers first. Your main HTTPS response should include HSTS:
curl -I https://app.example.com
Expected:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Then verify the WebSocket endpoint over TLS:
curl -i \
-N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Host: app.example.com" \
-H "Origin: https://app.example.com" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
https://app.example.com/realtime
You should see a 101 Switching Protocols response if the upgrade path is working.
For a quick sanity check on your headers, I’d run a free scan at headertest.com. It’s a fast way to confirm HSTS is actually present where you think it is.
Should you rely on HSTS for WebSockets?
No. Treat it as a safety net, not the primary design.
My practical checklist is:
- use
https://for pages - use
wss://for browser WebSockets - send HSTS on all HTTPS responses
- use
includeSubDomainsonly when every relevant subdomain is ready - redirect HTTP to HTTPS
- make sure the socket host has a valid cert
- don’t assume non-browser clients understand HSTS
That setup is boring, and boring is what you want for transport security. HSTS should reinforce your WebSocket security model, not compensate for sloppy URL handling.