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/socket becomes wss://example.com/socket if example.com is 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:

  1. HSTS applies per host.
  2. If includeSubDomains is set on the parent domain, it applies to WebSocket hosts under it.
  3. ws:// can be upgraded to wss:// by HSTS before the request leaves the browser.
  4. 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:

  • always on the HSTS header
  • explicit HTTP to HTTPS redirect
  • correct Upgrade and Connection headers
  • 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 includeSubDomains only 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.