Avoiding HSTS Lockout During Migration

Problem Statement

Strict-Transport-Security is a promise your site makes to a browser, and the browser keeps it whether or not you still can. Once a visitor has received a policy with a year-long max-age, their browser will refuse to connect to your hostname over anything but HTTPS with a valid certificate — no warning to click through, no fallback, for the remainder of that year. During a migration that turns an ordinary certificate mistake on the new origin into a hard outage for every returning visitor, and it is one of the very few migration failures you cannot fix by changing the server, because the instruction lives in the client. This page sits under TLS & Certificate Cutover and covers sequencing the policy so it cannot trap you.

When to Use This Approach

  • The site currently sends Strict-Transport-Security, or you are planning to enable it.
  • You are moving to a new origin, a new CDN, or a new certificate authority.
  • includeSubDomains is set and you are introducing or moving a subdomain.
  • The domain is on, or is a candidate for, the browser preload list.
  • You need a rollback path that works for visitors who have already cached the policy.

Step-by-Step Instructions

1. Read the Policy You Are Actually Sending

Check the live header rather than the configuration, because reverse proxies, CDNs and application frameworks all add this header and more than one of them may be doing so. The value that reaches the browser is the one that matters, and where two layers both set it the result depends on which one runs last.

# The live policy, as a browser receives it
curl -sI https://www.example.com/ | grep -i strict-transport-security
# Expect e.g. strict-transport-security: max-age=31536000; includeSubDomains

Check every hostname separately, not just the apex. includeSubDomains means the apex’s policy binds names that may be served by entirely different infrastructure with entirely different certificates — an assets host on a CDN, an API on another platform, a status page hosted by a vendor. Each of those inherits the promise and none of them is visible in the apex’s own configuration, which is why the enumeration has to come from the zone rather than from the web server.

2. Lower max-age Well Before the Window

Reduce the policy to a few minutes at least as long before cutover as the current max-age — the same logic as a DNS TTL reduction, and for the same reason: browsers that already hold the long value keep it until it expires. A short policy in force during the window means a mistake is forgotten in minutes rather than remembered for a year.

# Weeks ahead: drop the promise so caches age out before the window
add_header Strict-Transport-Security "max-age=300" always;

3. Check the Preload List Before Anything Else

Preloading is different in kind: the policy is compiled into the browser itself rather than cached from a response, so it applies to visitors who have never been to your site and cannot be shortened by changing a header. Removal takes months. If the domain is preloaded, the new origin’s TLS must be correct before a single request reaches it, and there is no version of the plan in which you find out afterwards.

# Does the apex declare preload? (Presence here is necessary, not sufficient —
# check the browser preload list itself for the authoritative answer.)
curl -sI https://example.com/ | grep -i 'strict-transport-security' | grep -o 'preload'

Deploy the reduction and then confirm it is actually what reaches the browser. More than one layer can set this header — an edge, a reverse proxy, the application framework, a security middleware — and the effective value is whichever one writes last. A configuration change that lowers the policy at the origin while a CDN continues to add a year-long one has achieved nothing, and the only way to know is to read the response.

4. Restore the Long Policy After the Soak

Raise max-age back to its normal value only once the new origin has served cleanly through a full soak window, and treat that restoration as a scheduled task with an owner. A migration that permanently leaves the site on a five-minute policy has quietly removed a security control, and nobody will notice until an audit.

# After the soak: restore the real policy in one deliberate change
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Why a cached HSTS policy removes the fallback A visitor with no cached policy sees a clickable certificate warning, while a visitor holding a cached HSTS policy is refused the connection outright with no way to proceed. The same broken certificate, two very different outcomes No cached policy browser shows an interstitial the visitor can proceed recoverable, and visible to you HSTS cached connection refused outright no click-through offered a hard outage for that visitor The right-hand case cannot be fixed from the server — the instruction is held by the client until its max-age expires. Which is why the policy is shortened before the window rather than after the problem.
HSTS is doing exactly what it was designed to do in both panels. The difference is that during a migration you are the party being locked out.

Worked Example

A retailer runs max-age=31536000; includeSubDomains and is moving to a new CDN. Six weeks before cutover they drop the policy to max-age=600, deploy it, and confirm with curl that the short value is being served. Over the following weeks the year-long policy ages out of returning visitors’ browsers as each one revisits and receives the shorter promise.

At cutover the new CDN is misconfigured for one subdomain — assets.example.com presents a certificate that does not include that name. Because includeSubDomains was in force, browsers holding the old policy would have refused the connection entirely; with the shortened policy the failure surfaces as a normal certificate error on that subdomain only, visible in monitoring within two minutes:

$ curl -sI https://assets.example.com/app.css
curl: (60) SSL: no alternative certificate subject name matches target host name

They correct the SAN list, reissue, and the subdomain recovers. Had the year-long policy still been cached, the same fix would have worked for new visitors and done nothing at all for the returning ones, whose browsers would have kept refusing until 2027.

Timeline for lowering and restoring the HSTS policy The long policy shortened well before cutover so cached copies age out, a short policy held through the window and soak, and the long policy restored only once the new origin has proven stable. Shorten before, hold through, restore after max-age 1 year max-age 300 — window and soak restored lower here — one old max-age before cutover restore here The left-hand boundary is the one people get wrong: lowering the policy a week before a year-long one expires changes nothing for returning visitors. Give the reduction at least as long as the value it replaces, exactly as with a DNS TTL. Put the restoration on the checklist with an owner — it is the step most often forgotten.
The middle band is the only period in which a TLS mistake on the new origin is cheaply recoverable for every visitor.

One subtlety about the restoration: raising max-age takes effect immediately for anyone who visits after the change, unlike lowering it, which only helps visitors who return before the old value lapses. That asymmetry is worth internalising because it is the reason the sequencing is one-directional — you must lower early and can restore whenever you like.

Verification

Confirm the policy in force and the absence of a preload trap, before and after the switch.

# 1. The live policy on the apex and on www
for h in example.com www.example.com; do
  echo -n "$h: "; curl -sI "https://$h/" | grep -i strict-transport-security
done

# 2. Every subdomain covered by includeSubDomains actually serves valid TLS
for h in assets.example.com api.example.com; do
  echo -n "$h: "; curl -sI "https://$h/" | head -1
done

# 3. No stray preload directive is being emitted
curl -sI https://example.com/ | grep -i strict-transport-security | grep -c preload

A clean result is a short max-age on every hostname during the window, a 200 from each subdomain covered by includeSubDomains, and a preload count of zero unless the domain is deliberately on the list.

How each HSTS directive can trap a migration The three parts of a Strict-Transport-Security header compared by what each one commits you to and the migration failure each can cause. Three directives, three different ways to be locked out Directive Commits you to Migration risk max-age HTTPS only, for that long a bad cert is unrecoverable until it expires includeSubDomains every subdomain too one uncertified subdomain breaks preload browsers that never visited months to remove — no fast exit The middle row catches people out most: enabling includeSubDomains commits every future subdomain, including ones created during the migration. Never add preload in the same window as a migration.
Each directive widens the promise, and each widening removes another category of recoverable failure.

FAQ

Can I just remove the header to cancel the policy? No, and this is the crux of it. Removing the header stops new promises being made; it does nothing about promises already held. A browser that received max-age=31536000 yesterday will continue enforcing it for a year whatever you serve today. The only way to shorten an in-flight policy is to serve a shorter max-age, which the browser adopts on its next visit — and that only reaches visitors who return before the old value expires.

Is max-age=0 a valid escape hatch? It clears the policy for any browser that receives it, so it is the fastest available remedy — but it only works over a successful HTTPS connection, which is precisely what a certificate failure prevents. That circularity is why it is not a rollback plan: by the time you want it, the affected visitors cannot reach you to receive it. It is useful for deliberately winding down HSTS on a healthy site, not for recovering from a broken one.

How does this interact with a domain change rather than a hosting move? The new domain has no cached policy at all, which makes it lower-risk in one direction and easy to underestimate in the other: the legacy domain is still serving redirects and still holds whatever policy it published. If the legacy certificate lapses while a cached policy is in force, returning visitors cannot even reach the redirect that would carry them to the new domain. Keep the legacy certificate valid for as long as the legacy redirects matter.

Should HSTS be enabled at all before a migration if it is not already? Not during the window. It is a worthwhile control and there is no urgency that justifies introducing it in the same change as a routing move, because doing so removes the click-through fallback at exactly the moment you are most likely to need it. Migrate first, soak, then enable HSTS with a short max-age and raise it gradually once the new estate has proven stable.

Related

← Back to TLS & Certificate Cutover