Web Security

Missing HSTS Header: What It Means, How to Fix It, and How to Prove You Fixed It

The cheapest finding on your report to fix — one header — and the easiest to get wrong in three specific ways. The exact fix for nginx, Apache, Caddy and Next.js, and how to prove it worked.

Webcuris Research

Security Engineering

·5 min read

A terminal row showing the header strict-transport-security: max-age=31536000; includeSubDomains

A scanner, a pentest report, or a compliance checklist just told you your site is missing the HSTS header, and the finding probably read like boilerplate. It is worth taking seriously anyway — not because it is dramatic, but because of everything on that report, this is the cheapest item to fix, the easiest to prove fixed, and one of the few with a well-documented way to break your own site while fixing it. This guide covers all three.

What HSTS actually stops

When somebody types your domain into a browser without the https://, the browser's first request goes out as plain HTTP. Your server answers with a redirect to HTTPS, and everything after that is encrypted — so it is tempting to conclude the redirect already solved the problem.

The gap is that first request. It travels in plaintext, and anyone positioned between the user and you — the classic example is a hostile network they happened to join — can answer it before your redirect does. From that moment the attacker holds the connection: they can keep the user on HTTP, proxy your real site through themselves, and read everything including session cookies. This is SSL stripping, and the user sees nothing unusual except a missing padlock most people never check.

Two versions of the same visit. Without HSTS, the first plaintext request is answered by an interceptor before the server's redirect can arrive. With HSTS, the browser rewrites the URL to https internally and nothing plaintext ever crosses the wire.
Two versions of the same visit. HSTS removes the plaintext leg entirely — after the first visit, the rewrite happens inside the browser.

HTTP Strict Transport Security closes the gap by removing the plaintext request entirely. The header is one line, sent over HTTPS:

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

Once a browser has seen it, it refuses to speak HTTP to your host for max-age seconds — typed URLs, old bookmarks and http:// links are all rewritten to HTTPS inside the browser, before any packet leaves the machine. There is nothing left on the wire for an attacker to intercept.

The fix, per server

Add the header to your HTTPS configuration. A Strict-Transport-Security header on a plain-HTTP response is ignored by every browser, per the specification — putting it there is the most common way to "fix" this finding without fixing anything. Make sure your HTTP-to-HTTPS redirect is in place first, so users actually reach the response that carries the header.

nginx — inside the server block that listens on 443nginx
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

The always matters in nginx: without it the header is dropped from error responses, and a browser that first meets your site on a 404 learns no policy.

Apache — in the VirtualHost for :443 (requires mod_headers)apache
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Caddyfilecaddy
example.com {
    header Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
next.config.ts — for a Next.js app serving its own headersts
const SECURITY_HEADERS = [
  {
    key: "Strict-Transport-Security",
    value: "max-age=63072000; includeSubDomains",
  },
];

export default {
  async headers() {
    return [{ source: "/:path*", headers: SECURITY_HEADERS }];
  },
};

The three mistakes that turn a fix into an outage

1. Starting with a year

max-age is a promise browsers hold you to. If you ship a one-year policy and then discover some corner of your site cannot serve HTTPS, your walk-back options are poor: you can serve max-age=0 to clear the policy, but only browsers that come back to your site over HTTPS ever receive it — everyone else keeps enforcing the old promise until it expires. Roll out in stages instead:

Stagemax-ageHold forWhat you are confirming
1300 (5 minutes)a dayNothing breaks when HTTPS is forced at all
286400 (1 day)a weekEvery page, asset and subdomain path works over HTTPS through real traffic
331536000 (1 year)ongoingThe policy you actually want, committed to with evidence
A staged rollout costs three config edits and removes the failure mode entirely.

2. includeSubDomains, three weeks later

includeSubDomains applies the policy to every subdomain of the host that served it. The failure arrives on a delay: somebody adds the directive on example.com, everything is fine, and weeks later an internal tool on staging.example.com or a vendor CNAME that only speaks HTTP stops loading — for exactly the people who visited the main site recently, which makes it maddening to reproduce. Inventory your subdomains before adding the directive, not after. Include the ones that only exist on the office network.

3. Preload on day one

The preload directive plus a submission at hstspreload.org bakes your domain into the browsers themselves, closing the first-visit gap. It is also close to irreversible: removal takes months to propagate through browser releases, and until it does, any part of your domain that needs HTTP is simply broken for everyone. Preload is the right end state for many sites — after a long, uneventful period of running includeSubDomains at a full-year max-age. It is a decision to schedule, not a checkbox to tick while fixing a finding. (For what it is worth: this site runs a two-year policy with includeSubDomains and has deliberately not preloaded yet, for exactly this reason.)

How to prove you fixed it

A fix you have not verified is a guess. Two checks, from outside your network:

1 — the header is on the HTTPS responsebash
curl -sI https://example.com | grep -i strict-transport-security
# expected:
# strict-transport-security: max-age=31536000; includeSubDomains
2 — plain HTTP still redirects, so browsers reach the headerbash
curl -sI http://example.com | head -3
# expected: HTTP/1.1 301 Moved Permanently
#           Location: https://example.com/

Check the apex and www separately — they are different hosts and each learns its own policy. In Chrome you can also query what the browser has actually recorded at chrome://net-internals/#hsts, which settles any argument about whether the policy arrived.

How this finding comes back

Almost every missing-HSTS finding we see on established sites is a regression, not an omission: the header was there until a config refactor rewrote the server block, a CDN migration moved header responsibility to a layer nobody configured, or a new load balancer quietly stopped passing custom headers through. The fix took one line; noticing it vanished took a customer, an audit, or luck.

That is the honest argument for checking continuously rather than once. This header is one of the checks in a free Webcuris scan — it reads what your site actually sends, flags the max-age if it is too short to protect anyone, and if you turn on monitoring, tells you the day a deploy drops the header instead of the month an auditor does.

Get the next one

One email when a new article goes out. No newsletter, no drip sequence, no sales follow-up.

Unsubscribe in one click. We never sell or share the address.

Keep reading

Contact

Talk to us.

Questions about what the engine checks, whether it fits your estate, or what it deliberately refuses to do. A person reads every message.

  1. 01You writePlain form, no qualifying call, no obligation. The marketing checkbox is optional and unticked.
  2. 02A person reads itMessages land with the team, not a queue-bot. Nothing is auto-replied.
  3. 03You get an answerTo the address you gave — including “this product is not the right fit”, when that is the honest answer.
Reporting a vulnerability?
Read the disclosure policy first — it tells you what is in scope and what to expect.
New messagereplies go to your email

We reply to this address, so a disposable one will not reach you.

+91

0 / 4000