MODRACXKENNETH D'SILVA

← Archive & Insights

HTTP Strict Transport Security (HSTS) Setup

One header removes the plaintext request every HTTPS redirect leaves behind. It is also the header most likely to take your site off the internet, so here is how to ramp it safely — and what preload actually commits you to.

By Kenneth D'SilvaReading Time: 23 min readCategory: Security & Compliance

1. The Redirect Nobody Looked At

Every ecommerce site I audit has a line in its web server config that redirects HTTP to HTTPS. Everyone considers the job done at that point. It isn't, and the reason is a single request that happens before the redirect.

A customer types shop.example.com into the address bar, or clicks an old link, or taps a bookmark saved in 2019. The browser has no reason to assume HTTPS, so it makes a plain HTTP request. Your server answers with a 301 to the HTTPS version and everything proceeds normally. Total elapsed time: a couple hundred milliseconds. Total number of unencrypted requests: one.

That one request carries the hostname in plaintext, carries the full path, and — this is the part that matters — carries any cookie not marked Secure. On a coffee shop network, a compromised router, a hotel captive portal, or a hostile ISP, someone in the middle sees it. Worse, they can answer it. The attacker doesn't have to break TLS; they just have to make sure the browser never gets to TLS, serving a proxied copy of your storefront over plain HTTP and relaying everything to the real site. The padlock never appears, which most customers will not notice.

HTTP Strict Transport Security is one header that removes that request from existence. Once a browser has seen it, that browser will not make a plain HTTP request to your domain again for as long as the policy lasts — it rewrites the URL internally before anything hits the network. No redirect, no plaintext hop, nothing to intercept.

It is also the header most likely to take your site off the internet if you get it wrong, because it is deliberately difficult to undo. Both halves of that are worth understanding before you deploy it.

2. What the Browser Actually Does

The header is short:

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

When a browser receives this over a valid HTTPS connection, it records an entry: this host is HTTPS-only, for this many seconds. From then until the policy expires, three things change.

Any attempt to load http://shop.example.com/anything is rewritten to https:// internally, before a connection is opened. Your server's redirect never runs because the browser never asks. This is why HSTS is faster as well as safer — you're removing a full round trip from the first visit of every session that starts without a scheme.

Certificate errors become fatal. Normally a browser shows an interstitial warning and lets a determined user click through. On an HSTS host it does not: there is no "proceed anyway" button, and the typed bypass phrases that work elsewhere do not work here. If your certificate expires, your site is simply gone for every visitor whose browser holds the policy. Not degraded. Gone.

Mixed content behaviour tightens, because subresources on the same host get the same upgrade treatment.

Two rules about the header itself. It is only honoured when received over HTTPS with a valid certificate — sending it over plain HTTP does nothing at all, which is a common misconfiguration in Nginx blocks that serve both. And the clock resets on every visit: each time a browser sees the header, the expiry is pushed out to the full max-age from that moment. A regular customer is under continuous protection; someone who hasn't visited in fourteen months is not.

3. The Three Directives

max-age

Seconds. 31536000 is a year, 63072000 is two. This is the one number people copy without thinking, and it deserves thought, because it is also how long a mistake lasts.

Set max-age=63072000 on a domain with a subdomain that can't do HTTPS and you have made that subdomain unreachable for two years for every browser that saw the header. You cannot call it back. Changing the header to max-age=0 only helps browsers that visit you again over HTTPS and receive the new value — which, for a subdomain that is now unreachable, they cannot do. That's the trap, and it is the reason the rollout section of this article exists.

includeSubDomains

Extends the policy to every subdomain, including ones you've forgotten about. This is the directive that causes essentially all HSTS incidents, and the list of casualties is predictable: legacy admin panels, a status page on shared hosting, an old staging environment, a partner integration endpoint, the marketing team's landing-page tool, an internal printer or NAS with a self-signed certificate on a subdomain of the corporate zone, a mail server's webmail interface.

It is also the directive that makes HSTS genuinely useful, because without it an attacker can target anything.shop.example.com — a hostname the browser has no policy for — and use it to set cookies on the parent domain. Cookie scoping does not respect the same-origin rules people assume it does, and a wildcard-adjacent host is a real attack path.

So you need it, and you need to enumerate your subdomains before you turn it on. Certificate Transparency logs are the fastest way to find hostnames nobody documented:

# Every hostname that has ever had a public certificate issued
curl -s "https://crt.sh/?q=%25.example.com&output=json" \
  | python3 -c "import sys,json;[print(n) for n in sorted({x for r in json.load(sys.stdin) for x in r['name_value'].split()})]"

Run that and then check each result. In my experience the list is between two and five times longer than whatever internal documentation claims, and the surprises are almost always old.

preload

The directive that solves the remaining hole, at the cost of being the hardest thing on this page to reverse. It deserves its own section.

4. The First Visit Problem

HSTS is trust-on-first-use. The browser only knows your policy after it has successfully connected once over HTTPS. On a device that has never visited you — a new phone, a fresh browser profile, a customer clicking your ad for the first time — the very first request is still plain HTTP, and that request is exactly the one an attacker wants.

The preload list closes it. It's a list of domains compiled into the browser itself, shipped with the binary. If your domain is on it, browsers treat it as HTTPS-only from the moment they're installed, before ever contacting you. Chrome maintains the list; Firefox, Safari, Edge and Opera consume it.

The requirements, all of which must hold continuously:

  • A valid certificate on the apex domain.
  • HTTP on port 80 redirects to HTTPS on the same host — not to a different hostname first.
  • All subdomains served over HTTPS, including www.
  • The header on the base domain with max-age of at least 31536000, plus includeSubDomains, plus preload.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

Submission is at hstspreload.org and the check is automated. Inclusion is not: it takes weeks, and it lands in a browser release rather than instantly.

Removal is the part to think hard about. Getting off the list means submitting a removal request, waiting for it to be processed, and then waiting for the browser releases that carry the change to reach your customers. Realistically several months, and users on old browser versions keep the old list indefinitely. There is no emergency exit.

My rule: preload the domain when the business is confident it will never need plain HTTP on any hostname under it, for any reason, for years. For a dedicated ecommerce domain that's usually an easy yes. For a corporate domain that also hosts twenty years of departmental subdomains, it's usually a no, and the right move is to preload the shop's own domain rather than the parent.

A pattern worth knowing: put the storefront on its own registrable domain if you can, preload that, and leave the sprawling corporate domain alone. It sidesteps the entire argument.

5. Implementation

Nginx

server {
    listen 80;
    server_name shop.example.com www.shop.example.com;
    # No HSTS header here — it is ignored over plain HTTP anyway
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name shop.example.com;

    ssl_certificate     /etc/letsencrypt/live/shop.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}

Two things people get wrong here. First, always — without it the header is omitted on error responses, and a browser that first meets your site via a 502 learns nothing. Second, and this one has bitten me: Nginx's add_header does not inherit into any block that declares its own add_header. If a location sets a single header, it silently loses every header from the parent. Grep your config for add_header and check each block repeats what it needs.

The redirect deserves a note too. $host rather than a hardcoded hostname keeps the redirect on the same host, which the preload requirements demand. Redirecting http://example.com straight to https://www.example.com is a very common config and it fails preload validation, because the first hop changes host.

Apache

<VirtualHost *:80>
    ServerName shop.example.com
    RewriteEngine On
    RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
</VirtualHost>

<VirtualHost *:443>
    ServerName shop.example.com
    SSLEngine on

    <IfModule mod_headers.c>
        Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
    </IfModule>
</VirtualHost>

Use set, not append or add. Duplicate HSTS headers with different values produce undefined behaviour across browsers, and the way you get duplicates is a plugin or an application framework adding its own on top of yours.

Cloudflare and other CDNs

Cloudflare exposes HSTS as a toggle with fields for each directive, which is convenient and slightly dangerous — the settings live in a dashboard rather than in version control, and the person who enables includeSubDomains at 11pm may not be the person who knows about the legacy subdomain. If you can express it as a Transform Rule or a Worker instead, do; at minimum, document it somewhere your team reads.

Watch for the origin sending its own HSTS header as well. Two sources, two values, and whichever wins is not always the one you intended. Pick one layer and turn it off at the other.

Magento 2

Magento has HTTPS settings under Stores → Configuration → General → Web, and enabling "Use Secure URLs" is necessary but unrelated to HSTS — it controls the URLs Magento generates, not the header. Set the header at the web server. If you must do it in the application, a response plugin works, but there is no good reason to spend an application request cycle on a static header.

What does need attention on Magento is the base URL configuration. If web/unsecure/base_url still points at http://, Magento will generate plain HTTP links in emails, in the sitemap, and in canonical tags. HSTS will save the browser but you'll have an SEO mess of HTTP canonicals pointing at a site that only speaks HTTPS. Set both base URLs to https:// and flush the config cache.

Shopify

Shopify serves HSTS on its own domains and on custom domains once SSL is provisioned. You don't configure it and you can't preload a Shopify-hosted domain yourself. What you do own is making sure every link, redirect, and email template uses HTTPS, and that any subdomain you run outside Shopify — a blog on another host, a help centre, a status page — is HTTPS too, because a preloaded parent would take them down.

6. Order of Operations Matters

A subtlety that costs people a rollout: the header must be on the HTTPS response, and the HTTP request must redirect to HTTPS on the same host. Get the ordering wrong and the policy is never delivered.

The chain you want, for a customer typing the bare domain:

http://example.com/       → 301 → https://example.com/     (same host, HSTS applies from here)
https://example.com/      → 301 → https://www.example.com/ (canonical host change, over TLS)

The chain to avoid:

http://example.com/       → 301 → https://www.example.com/  (host changed on the insecure hop)

The second is one redirect shorter and it is what most people configure, because it looks more efficient. It fails preload validation, and more importantly the apex domain never gets to deliver its own policy, so http://example.com stays a plaintext request forever.

Check yours:

curl -sIL http://example.com/ | grep -Ei '^(HTTP/|location:|strict-transport)'

7. What Breaks

Every incident I've seen traces back to includeSubDomains meeting a host nobody remembered. The specific casualties, so you can go look for yours:

Internal tooling on subdomains. Jenkins, Grafana, a wiki, an old ticketing system — frequently on tools.example.com with a self-signed certificate that everyone has clicked through for years. After HSTS, that click-through is gone. The tool is unreachable until it gets a real certificate, and the people affected are your own engineers, mid-incident, at the worst possible moment.

Development and staging. dev.example.com on plain HTTP, or with a certificate that expired in 2022. Same failure, and it will be discovered by a developer who then spends an hour convinced they broke their own machine.

Devices. Printers, NAS boxes, cameras, warehouse scanners, and building management systems with self-signed certificates on internal hostnames. If those hostnames sit under a domain you preload, they become unreachable from any browser. I have seen this take out a warehouse's label printers on a Monday morning.

Partner and legacy endpoints. An integration that still posts to http://api.example.com. Note that HSTS is a browser mechanism — a server-to-server client typically ignores it — so this often survives, but any browser-based partner tooling will not.

Expired certificates. The one that hurts most because it's not a subdomain problem. Without HSTS, an expired cert gives visitors a warning they can bypass; you lose some traffic and fix it within the hour. With HSTS, an expired cert is a hard outage with no bypass for anyone. This raises the stakes on certificate renewal from "important" to "existential," and it is the reason the monitoring section below is not optional.

8. Cookies, and the Half This Doesn't Solve

HSTS stops the browser making plaintext requests. It does nothing about cookies that were already scoped badly, and on an ecommerce site the cookies are usually where the actual value sits. The two controls belong in the same piece of work.

Three flags, and all three matter:

Secure tells the browser never to send this cookie over plain HTTP. Without it, that one pre-HSTS request in the opening of this article carries the session identifier in clear text. With HSTS deployed the request never happens, so the flag looks redundant — but it isn't, because HSTS is per-browser and per-device, and a customer on a brand-new phone has no policy yet. Set both.

HttpOnly keeps JavaScript from reading the cookie. This is what limits the damage of an XSS bug or a compromised third-party tag from stealing sessions wholesale. Session cookies should always have it; cookies your front end genuinely needs to read should be a short, deliberate list you can name.

SameSite controls whether the cookie rides along on cross-site requests. Lax is the sensible default and is what browsers now assume when the attribute is absent. None requires Secure and should be reserved for cookies that genuinely need to work in a third-party context — an embedded checkout iframe, for instance. Every time I see SameSite=None on a first-party session cookie, it was set to fix a symptom rather than a cause.

There's also a naming convention worth adopting, because it's enforced by the browser rather than by your discipline:

Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax

A cookie whose name begins __Host- is only accepted if it is Secure, has Path=/, and has no Domain attribute. That last constraint is the useful one: it means the cookie cannot be set by a subdomain, which closes the cookie-injection path that includeSubDomains exists to mitigate. If you can rename your session cookie, this is a genuine hardening step that costs one deploy.

Audit what you're actually sending. It takes a minute:

// Run on your storefront, logged in, and read the flags in DevTools →
// Application → Cookies. Anything without Secure and HttpOnly needs a reason.
document.cookie.split('; ').map(c => c.split('=')[0]).sort().forEach(n => console.log(n));
// Note: HttpOnly cookies will NOT appear here — which is the point.
// If your session cookie shows up in this list, it is readable by every
// script on the page, including every third-party tag.

That last comment is the test I actually use. If the session cookie appears in document.cookie, stop reading this article and go fix that first. It is a larger problem than the header you came here for.

9. Does It Help SEO?

Directly, no. There is no ranking boost for sending the header, and no crawler behaviour that changes because of it. Anyone selling HSTS as an SEO tactic is padding a deliverable.

Indirectly, three real effects, in order of how much they matter.

You remove a redirect hop. For visitors arriving without a scheme — typed URLs, old links, some app referrals — the plain HTTP request and its 301 disappear entirely. That's typically 100 to 300ms off the start of the navigation, and it lands squarely in Time to First Byte, which feeds Largest Contentful Paint. It is not a large win, but it is a free one on a metric Google measures.

You stop leaking a duplicate-content surface. Sites with a half-finished HTTPS migration often serve both schemes with 200s, and then argue with their canonical tags. HSTS forces the issue: browsers stop asking for HTTP, and you're pushed into making the redirects correct. The SEO benefit is really the discipline, not the header.

HTTPS is a ranking signal. A light one, confirmed years ago, and HSTS is part of doing HTTPS properly. Treat it as hygiene rather than leverage.

The honest framing for a stakeholder conversation: this is a security control with a small performance side effect. If you need SEO justification to get it prioritised, use the TTFB argument, and be straight that it's worth single-digit milliseconds on most pages rather than promising a ranking change.

10. The TLS Configuration That Should Come With It

Committing to HTTPS-only raises the stakes on your TLS setup, because there is no longer a degraded path. A few things worth getting right at the same time.

Protocol versions. TLS 1.2 and 1.3 only. TLS 1.0 and 1.1 are deprecated, browsers have removed support, and PCI DSS has required their retirement for years. If you still have them enabled it's usually because a config file was written in 2016 and never revisited.

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;

ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;

ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;

ssl_prefer_server_ciphers off looks wrong to anyone who learned this a decade ago, but with a modern cipher list it is now the recommended setting — clients generally know better than your config file which cipher their hardware accelerates. TLS 1.3 negotiates its own suites regardless.

OCSP stapling is worth enabling for the latency alone. Without it the browser may make its own request to the certificate authority to check revocation, adding a round trip to a third party you don't control. Stapling folds the answer into your handshake.

Certificate automation, and alerting on the automation. Once HSTS is live with a long max-age, an expired certificate is a total outage. ACME with automatic renewal is the baseline. What people miss is monitoring the renewal job rather than the expiry date — a broken cron is silent for sixty days and then catastrophic on day ninety. Alert when a renewal attempt fails, not when the certificate gets close to expiring.

CAA records. A DNS record naming which certificate authorities may issue for your domain. It doesn't affect visitors and it takes two minutes, and it constrains the mis-issuance risk that HSTS explicitly does not address:

example.com.  IN  CAA  0 issue "letsencrypt.org"
example.com.  IN  CAA  0 iodef "mailto:[email protected]"

The iodef line asks CAs to report policy violations to you, which is a free early warning that someone attempted issuance you didn't authorise.

11. Rolling It Out Without Breaking Anything

The whole risk profile of this header comes from max-age being a commitment you can't retract. So ramp it. Each stage below stays in place long enough to expose problems while the blast radius is still small.

Stage zero — inventory. Pull the Certificate Transparency list above. Add anything in your DNS zone that CT doesn't know about. For each host, record whether it serves HTTPS with a valid public certificate. This is the entire risk assessment, and skipping it is how outages happen.

Stage one — five minutes, no subdomains.

Strict-Transport-Security: max-age=300

If something is badly wrong, it self-heals in five minutes. Watch error rates and support volume for a day.

Stage two — one week, no subdomains.

Strict-Transport-Security: max-age=604800

Long enough that a real problem surfaces, short enough that waiting it out is survivable.

Stage three — add subdomains, short age.

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

This is the dangerous stage and it is deliberately at a one-week age. Tell your internal teams before you ship it, in a channel they read, with a sentence explaining what to report. Most subdomain breakage is invisible from the outside — it's your own staff who hit it first.

Stage four — a year.

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

Sit here for at least a month before considering preload. Nothing is gained by rushing, and this is the last stage that is comfortably reversible.

Stage five — preload, if and only if the business is sure. Two years, the preload directive, and a submission. Make this a decision with a named owner rather than an engineering detail, because its consequences outlast most people's tenure.

Between stages, verify against real browsers rather than only curl. Chrome's internal page at chrome://net-internals/#hsts lets you query a host, see the stored policy, and delete it — which is indispensable for testing, because once your own browser has cached a long max-age you'll be unable to reproduce a first-time visitor's experience without clearing it.

12. Mixed Content and the Long Tail

HSTS upgrades navigations to your own host. It does not fix a page that references http:// resources on other hosts, and on a store with years of content behind it, those references are everywhere.

The usual hiding places, in the order I find them: product descriptions with hardcoded image URLs pasted in by a merchandiser years ago, CMS blocks and static blocks, email templates, the XML sitemap, canonical tags generated from a stale base URL, hardcoded links in theme files, and third-party widget snippets from vendors who never updated their documentation.

For a database-backed platform, go and look rather than assuming:

-- Magento: content that still references plain HTTP on your own domain
SELECT entity_id, LEFT(value, 120) AS snippet
FROM catalog_product_entity_text
WHERE value LIKE '%http://shop.example.com%'
LIMIT 50;

SELECT block_id, title
FROM cms_block
WHERE content LIKE '%http://%';

SELECT page_id, title, identifier
FROM cms_page
WHERE content LIKE '%http://%';

Take a backup, then rewrite them. And prefer protocol-relative-free absolute HTTPS URLs over the old //example.com/image.jpg trick — protocol-relative URLs were a workaround for a mixed-scheme era that no longer exists, and they break when content is consumed outside a browser, such as in an email client or a feed.

As a safety net while you work through the backlog, there's a CSP directive that rewrites insecure subresource requests on the fly:

Content-Security-Policy: upgrade-insecure-requests

It tells the browser to try https:// for any http:// subresource before giving up. It is genuinely useful during a migration, and it is not a fix — if the remote host doesn't serve HTTPS, the resource fails anyway, and you've simply hidden the problem from yourself. Use it as scaffolding, with a ticket to remove it.

To find what's actually failing in the wild rather than what you think is failing, collect reports:

Content-Security-Policy-Report-Only: default-src https: 'unsafe-inline' 'unsafe-eval'; report-uri /_csp-report

Run that for a fortnight before you tighten anything. Real customer traffic hits pages your QA never opens — old campaign landing pages, deep category filters, order histories with products discontinued in 2018 — and those are precisely the pages with the stale URLs.

13. Verifying and Monitoring

A quick check of the header and the redirect chain:

#!/usr/bin/env bash
set -uo pipefail
host="${1:?usage: hsts-check.sh example.com}"

echo "— redirect chain from plain HTTP"
curl -sIL "http://$host/" | grep -Ei '^(HTTP/|location:)'

echo "— header on HTTPS"
curl -sI "https://$host/" | grep -i strict-transport-security \
  || echo "  MISSING"

echo "— header on an error response (should still be present)"
curl -sI "https://$host/definitely-not-a-page" | grep -i strict-transport-security \
  || echo "  MISSING on error responses — add 'always' in Nginx"

echo "— certificate expiry"
echo | openssl s_client -servername "$host" -connect "$host:443" 2>/dev/null \
  | openssl x509 -noout -enddate

That last check is the one to automate and alert on. Once HSTS is deployed with a long max-age, certificate expiry is no longer a degradation, it's an outage with no workaround. Alert at thirty days, again at seven, and page someone at two. If you're on ACME with automated renewal, alert on the renewal job failing rather than only on the expiry date — by the time the date is close, the automation has already been broken for weeks.

Add the header check to your deploy smoke tests. Headers regress silently: a CDN configuration change, a new location block, a platform upgrade. Nothing in a normal deploy tells you the policy stopped being sent, and browsers that already hold it won't notice for a year — so the failure is invisible right up until the day it isn't.

14. A Migration, Start to Finish

A janitorial supplies retailer, roughly £14m online, Magento 2 behind Cloudflare, one corporate domain shared with the rest of the business. They wanted preload because a penetration test had flagged its absence.

The inventory. CT logs returned 31 hostnames. Their documentation listed nine. Of the 22 surprises: four were long-dead campaign microsites, three were staging environments from a 2019 replatform, two were partner-hosted subdomains for a returns portal and a loyalty programme, one was the warehouse's label printer interface, and the rest were dormant. Six of the 31 could not serve valid HTTPS.

The argument. The returns portal was the blocker. It was hosted by a third party on returns.example.com with a certificate that had lapsed twice in the previous year, and the vendor's response to "please keep this valid forever" was not reassuring. Preloading the corporate domain would have made every certificate lapse at that vendor a hard outage for a customer-facing service.

What we did instead. Stages zero through four on the corporate domain — HSTS with includeSubDomains at a one-year age, no preload. That required getting the six broken hosts onto valid certificates, which took three weeks and mostly consisted of decommissioning four of them. The staging environments moved to an internal-only domain, which they should have been on anyway.

The preload question. They didn't preload the corporate domain, and I'd make the same call again. The storefront's own vanity domain, used for campaigns and with nothing else under it, was preloaded instead. That gave them the pen-test tick on the domain that mattered and left the messy parent alone.

What went wrong. Stage three, exactly as predicted, and not on a customer-facing host. The label printer went unreachable and the warehouse escalated within about forty minutes. We'd announced the change in an engineering channel that warehouse operations did not read. A one-week max-age meant we could have simply waited it out; in practice the printer got a proper certificate the same afternoon. The lesson was about who gets told, not about the header.

The measurable outcome. TTFB for sessions starting from a typed URL improved by about 180ms on median, which is real but small — those sessions were roughly 6% of traffic. The security outcome was the point, and the inventory that fell out of it was arguably worth more than the header: they decommissioned nine hostnames nobody had looked at in years, two of which were running unpatched software.

15. The Rest of the Header Set

HSTS rarely ships alone, and the headers that belong beside it are cheap enough that there's no reason to make them a separate project. A defensible baseline for a storefront:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(self)" always;
add_header Content-Security-Policy "frame-ancestors 'self'" always;

X-Content-Type-Options: nosniff stops the browser second-guessing your Content-Type. The attack it prevents is a user-uploaded file that the server labels as an image being sniffed as HTML and executed — which matters on any site that lets customers upload anything, and most do somewhere.

frame-ancestors 'self' is the modern replacement for X-Frame-Options, and stops your pages being framed by someone else's site. On a checkout, that's the clickjacking defence. Keep X-Frame-Options: SAMEORIGIN alongside it only if you still care about very old browsers; otherwise the CSP directive is the one that counts.

Permissions-Policy disables browser capabilities you don't use. The value above is a starting point rather than a recommendation — audit what your site actually needs, because payment=(self) matters if you use the Payment Request API and blocking it silently breaks Apple Pay and Google Pay buttons. That is a mistake worth avoiding in the same week you're already changing headers, because it presents as a conversion drop with no obvious cause.

Notice what is not in that list: a full Content-Security-Policy with a script allowlist. That one is a project rather than a line, and trying to ship it in the same change as HSTS means that when something breaks you won't know which header did it. Do HSTS, get it stable, then start CSP in report-only mode. Sequencing security headers one at a time is slower and it is the only way to keep the debugging tractable.

One operational note that applies to the whole set: put them in one place. Headers scattered across a CDN dashboard, a web server config, and application middleware are impossible to reason about, and duplicates with conflicting values are worse than absences. Pick the layer, own it there, and turn the others off.

16. Questions People Ask

"Can I test HSTS without committing?" Yes — that's what max-age=300 is for. Five minutes of exposure, and any mistake evaporates while you're still watching the dashboard. Never test with a long age on the theory that you'll change it later.

"How do I undo it?" Set max-age=0 and keep serving it over HTTPS. Browsers that visit again pick up the new value and drop the policy. Browsers that don't visit keep the old one until it expires. If you're preloaded, this doesn't help — removal from the list is a separate, months-long process.

"A subdomain broke and I need it back now." Your options are to give that host a valid certificate — almost always the right answer and usually achievable within the hour with ACME — or to wait out the max-age. Individual users can clear the entry via chrome://net-internals/#hsts, which is a workable stopgap for a handful of internal staff and useless for customers.

"Does it protect against a compromised certificate authority?" No. That was HPKP's job, and HPKP was removed from browsers because it was too easy to permanently break your own site with it. Certificate Transparency and CAA records are the current answers to that problem. HSTS only guarantees that the connection is encrypted, not that the certificate is one you'd have chosen.

"What about APIs and non-browser clients?" HSTS is browser behaviour. A curl script, a mobile app's HTTP client, or a partner's server-side integration generally ignores it. Don't rely on HSTS to force those clients onto HTTPS — disable plain HTTP at the server for API hostnames if you need that guarantee.

"Should www and the apex both send it?" Yes. They're distinct hosts as far as HSTS is concerned, and both should carry the policy. With includeSubDomains on the apex, www is covered — but sending it from both is belt and braces and costs nothing.

"Our security scanner says it's missing on some pages." Nginx inheritance, nine times in ten. A location block set one header and silently dropped the rest. Check the specific URL the scanner tested, and check a 404 while you're there.

17. The Part That Isn't About the Header

Adding Strict-Transport-Security takes one line and about a minute. If your domain is simple — a dedicated storefront domain, HTTPS everywhere, no forgotten hosts — go and add it with a short max-age today, ramp it over a few weeks, and you're done.

The reason this article is long is that the header is not really the work. The work is finding out what actually exists under your domain, which for most organisations is a genuinely uncomfortable exercise. The retailer in the case study found nine hostnames nobody owned and two running software with known vulnerabilities. They went looking because HSTS forced them to. That inventory was worth more than the header, and they would never have done it otherwise.

So treat the ramp as the deliverable and the header as the by-product. Enumerate your hosts. Get certificates onto the ones that matter, decommission the ones that don't, and find out who owns the rest. Then turn the policy on, slowly, and let the short max-age catch whatever you missed — because you will have missed something, and the entire design of the rollout above exists to make that survivable.