If you serve HTTPS and you’re not using HSTS, you still have a weak first step.

That sounds harsh, but it’s true. A site can have a perfectly valid TLS setup and still be vulnerable to downgrade tricks that push users onto plain HTTP before HTTPS ever gets a chance. HSTS fixes that by telling browsers: “for this site, never use HTTP again.”

That one rule shuts down a whole class of annoying and very real attacks.

What HSTS actually does

HSTS stands for HTTP Strict Transport Security. It’s a response header:

Strict-Transport-Security: max-age=31536000; includeSubDomains

When a browser receives that header over a valid HTTPS connection, it stores a policy for your domain. For the duration of max-age, the browser will:

  • automatically convert http://example.com to https://example.com
  • refuse to bypass TLS certificate errors for that domain
  • optionally apply the rule to all subdomains if includeSubDomains is set

That means after the browser learns the HSTS policy once, it stops making insecure HTTP requests to your site.

The downgrade problem HSTS is designed to stop

Without HSTS, the first request is often still vulnerable.

A user types:

example.com

The browser may try:

http://example.com

Your server then redirects:

HTTP/1.1 301 Moved Permanently
Location: https://example.com

That redirect is normal, but the first request was plain HTTP. If an attacker is on the same network, they can interfere before the browser reaches HTTPS.

Classic attack flow:

  1. User connects on public Wi-Fi
  2. Attacker intercepts the initial HTTP request
  3. Instead of letting the browser upgrade to HTTPS, attacker serves a fake HTTP version
  4. User stays on insecure HTTP
  5. Attacker reads or modifies traffic

That’s the basic HTTPS downgrade or SSL stripping pattern.

If HSTS is already cached, the browser never sends that first HTTP request. It upgrades locally before anything hits the network. That’s the whole win.

HSTS does not protect the very first visit

This is the part people gloss over.

HSTS only works after the browser has seen the header once. If it’s a brand-new visitor, the first connection can still be attacked unless the domain is preloaded.

So there are really two protection levels:

  • Regular HSTS: protects repeat visits after the browser learns the policy
  • HSTS preload: protects even the first visit because the browser ships with your domain baked into its preload list

If you want strong downgrade resistance, preload is the endgame. But don’t rush into it casually.

The header fields that matter

A typical production header looks like this:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

max-age

How long the browser should remember the policy, in seconds.

Examples:

  • max-age=300 → 5 minutes
  • max-age=86400 → 1 day
  • max-age=31536000 → 1 year

For rollout, I usually start small and increase gradually.

includeSubDomains

Applies the rule to all subdomains too.

That’s great if every subdomain supports HTTPS correctly. It’s dangerous if you still have forgotten legacy hosts, old admin panels, or random DNS entries pointing somewhere weird.

preload

Signals intent to join browser preload lists.

This token alone does nothing unless you actually submit the domain for preload and meet browser requirements, but it’s required for preload eligibility.

A safe rollout strategy

The most common HSTS mistake is enabling a one-year policy with includeSubDomains on a messy domain and then discovering some forgotten subdomain breaks.

Use a staged rollout instead.

Stage 1: short cache

Strict-Transport-Security: max-age=300

Watch for breakage.

Stage 2: longer cache

Strict-Transport-Security: max-age=86400

Then move to weeks or months.

Stage 3: production policy

Strict-Transport-Security: max-age=31536000; includeSubDomains

Stage 4: consider preload

Only after you’ve verified:

  • HTTPS works on the apex domain
  • HTTPS works on www if you use it
  • all subdomains are HTTPS-capable
  • HTTP redirects cleanly to HTTPS everywhere
  • certificates are valid and renewed reliably

If you want a quick check of your current headers, run a scan at headertest.com.

Nginx configuration

Nginx is straightforward, but one detail trips people up: use always so the header is added on error responses too.

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;

    location / {
        proxy_pass http://app;
    }
}

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

If you’re testing rollout, lower the max-age first:

add_header Strict-Transport-Security "max-age=300" always;

Apache configuration

For Apache, make sure mod_headers is enabled.

<VirtualHost *:443>
    ServerName example.com
    ServerAlias www.example.com

    SSLEngine on
    SSLCertificateFile /etc/ssl/example/cert.pem
    SSLCertificateKeyFile /etc/ssl/example/key.pem

    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"

    DocumentRoot /var/www/html
</VirtualHost>

<VirtualHost *:80>
    ServerName example.com
    ServerAlias www.example.com
    Redirect permanent / https://example.com/
</VirtualHost>

Same advice here: don’t jump straight to a one-year policy unless you’re sure.

Express / Node.js

If you run Express directly, you can set the header yourself:

const express = require('express');
const app = express();

app.use((req, res, next) => {
  if (req.secure) {
    res.setHeader(
      'Strict-Transport-Security',
      'max-age=31536000; includeSubDomains'
    );
  }
  next();
});

app.get('/', (req, res) => {
  res.send('Hello over HTTPS');
});

app.listen(3000);

If Express is behind a reverse proxy, make sure req.secure works correctly:

app.set('trust proxy', 1);

Or use Helmet, which is what I’d usually do in a real app:

const express = require('express');
const helmet = require('helmet');

const app = express();

app.use(
  helmet.hsts({
    maxAge: 31536000,
    includeSubDomains: true,
    preload: false
  })
);

app.get('/', (req, res) => {
  res.send('Secure app');
});

app.listen(3000);

Official docs for Helmet are here: https://helmetjs.github.io/

CDN and load balancer gotchas

A lot of teams configure HSTS in the app and assume they’re done. Then a CDN, edge worker, or load balancer strips or overrides the header.

Check the actual response seen by the browser, not just your origin server.

Also watch for these edge cases:

  • CDN serves some paths without HSTS
  • static asset hostnames don’t support HTTPS
  • old subdomains are still reachable over HTTP
  • redirect chains bounce across multiple hosts before landing on HTTPS

HSTS is domain policy. Messy hostnames ruin clean policies.

Verifying that HSTS is working

You should verify three things:

1. The header is present on HTTPS responses

curl -I https://example.com

Expected output:

Strict-Transport-Security: max-age=31536000; includeSubDomains

2. HTTP redirects to HTTPS

curl -I http://example.com

Expected output:

HTTP/1.1 301 Moved Permanently
Location: https://example.com/

3. Browser has stored the policy

Use browser dev tools or browser HSTS inspection pages during testing. This matters because the browser behavior is the real enforcement point.

Common mistakes

I’ve seen the same HSTS failures over and over.

Setting HSTS on HTTP responses

Browsers ignore HSTS sent over plain HTTP. It must be delivered over valid HTTPS.

Enabling includeSubDomains too early

This is the one that bites hardest. If one forgotten subdomain doesn’t support HTTPS, users can get locked into failures.

Using a huge max-age during testing

If you send:

Strict-Transport-Security: max-age=31536000

you’ve told browsers to remember that policy for a year. Rolling it back is not immediate for users who already cached it.

Assuming preload is easy to undo

Preload is powerful and annoying to reverse. Treat it like a one-way door unless you have a very good reason and a clean domain inventory.

Forgetting certificate error behavior

HSTS makes certificate problems harsher by design. Users can’t click through warnings on HSTS hosts. That’s a feature, but it means your certificate automation needs to be solid.

Disabling or reducing HSTS

If you need to turn it down, serve:

Strict-Transport-Security: max-age=0

That tells browsers to delete the cached policy.

But this only works after the browser successfully reaches your site over HTTPS and receives the new header. If you broke HTTPS badly, users may still be stuck until the cached policy expires or you restore valid TLS.

That’s why careful rollout matters.

When HSTS is worth it

Honestly, almost always.

If your site is fully HTTPS and you control your subdomains, HSTS is low effort and high value. It closes the downgrade gap between “supports HTTPS” and “actually forces HTTPS in the browser.”

That distinction matters. Attackers love soft edges, and the initial HTTP request is one of them.

My practical recommendation:

  1. Redirect all HTTP to HTTPS
  2. Deploy HSTS with a low max-age
  3. Increase to one year once stable
  4. Add includeSubDomains only when you’ve audited the whole domain
  5. Move to preload only when you’re absolutely sure

That’s how you prevent downgrade attacks without creating a self-inflicted outage.