Fixing HSTS Issues on localhost & Dev

A dev server that suddenly refuses to load over http:// — with NET::ERR_CERT_AUTHORITY_INVALID and no Proceed anyway link — is almost always a cached HSTS policy, not a broken build. This guide is part of the HTTP Strict Transport Security (HSTS) deep dive reference, and it isolates the one failure mode that bites developers hardest: a policy set with includeSubDomains on a shared apex domain reaches down onto localhost-style dev hosts and forces every request to HTTPS, then stays cached in the browser long after you stop sending the header. The fix has two halves — evict the stored policy from the browser, and stop staging from emitting a policy that leaks into development in the first place.

Configuration Syntax & Exact Values

The header that causes the trouble is ordinary and correct for production. The damage comes from where its scope lands:

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

Note the one host the specification exempts: localhost itself is never subject to HSTS. RFC 6797 §8.2 requires that HSTS Host names be resolvable via DNS, and browsers explicitly skip the literal localhost and 127.0.0.1. The problem is never the bare word localhost; it is a named dev host under a real registrable domain that inherits includeSubDomains from its apex.

How includeSubDomains reaches dev hosts A tree showing an apex domain sending includeSubDomains, whose policy propagates to production, staging, and a named local dev host, while bare localhost is exempt. example.com sends includeSubDomains www.example.com HTTPS enforced — correct staging.example.com HTTPS enforced — usually ok dev.example.com forced HTTPS — breaks http dev 127.0.0.1 via /etc/hosts still HSTS-locked localhost / 127.0.0.1 (bare) exempt by spec — never affected
A named dev host under a registrable apex inherits includeSubDomains; only the literal localhost is exempt.

Server-Side Configuration

The correct server-side fix is subtractive: do not emit an enforcing HSTS policy from any environment a developer resolves locally, and never send includeSubDomains or preload from staging. Emit the header only on genuine public production hosts.

Nginx

Gate the header on the server name so staging and dev virtual hosts never send it. In production:

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

In staging, either omit it entirely or send a short, subdomain-scoped value with no includeSubDomains:

# staging.example.com — bounded, self-only, easy to evict
add_header Strict-Transport-Security "max-age=300" always;

The always flag forces emission on 4xx/5xx responses too; without it the header disappears from error pages and your verification looks inconsistent. Crucially, max-age=300 with no includeSubDomains keeps the policy on staging alone and lets it lapse in five minutes, so a mis-set staging header cannot pin a developer’s machine for two years.

Apache

# Production vhost only
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
# Staging vhost — never includeSubDomains, never preload
Header always set Strict-Transport-Security "max-age=300"

always appends the header even on internally generated error documents, which the default onsuccess condition skips. Requires mod_headers (a2enmod headers). Keep the two directives in separate <VirtualHost> blocks so the production value can never be inherited by the staging host.

Node/Helmet

Bind the directive to NODE_ENV so it is impossible to ship the enforcing value from a dev process. This is the Helmet/Express pattern:

const helmet = require('helmet');

if (process.env.NODE_ENV === 'production') {
  app.use(helmet.hsts({ maxAge: 63072000, includeSubDomains: true, preload: true }));
}
// no HSTS in development — the http dev server stays reachable

Helmet’s hsts middleware defaults to includeSubDomains: true, so calling helmet() unconditionally in a shared app factory is a common way dev hosts get locked. The if guard is the fix.

Django

Django’s SecurityMiddleware reads these from settings, so drive them from an environment split. This applies to a Django or FastAPI deployment:

# settings/production.py
SECURE_HSTS_SECONDS = 63072000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

# settings/local.py
SECURE_HSTS_SECONDS = 0            # emit no HSTS header at all
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False

When SECURE_HSTS_SECONDS = 0, SecurityMiddleware omits the header entirely, which is exactly what a local runserver needs.

Diagnostic & Verification Steps

Confirm whether a host is sending the policy at all:

curl -sI https://staging.example.com | grep -i strict-transport-security

Expected output on a correctly scoped staging host:

strict-transport-security: max-age=300

If you instead see includeSubDomains or preload here, that is the leak — a developer resolving any sibling host under example.com will be pinned.

Query the browser’s stored policy — this is the single most useful command for this scenario. In Chrome, open chrome://net-internals/#hsts, use the Query HSTS/PKP domain box, and enter the affected host:

Expected output when a policy is cached:

static_sts_domain: false
dynamic_sts_domain: dev.example.com
dynamic_sts_include_subdomains: true
dynamic_sts_expiry: 1830000000  (a future Unix timestamp)

A non-empty dynamic_sts_domain confirms the browser — not the server — is forcing HTTPS. static_sts_domain: true instead means the host is on the built-in preload list, which no per-host deletion can clear.

Confirm the redirect is client-side, not a server 301: with the policy cached, the browser rewrites http:// to https:// internally and reports a 307 Internal Redirect:

# The server is NOT involved — this is the browser upgrading the scheme
# DevTools → Network shows: Status 307 Internal Redirect
# Response Headers → Non-Authoritative-Reason: HSTS

Seeing Non-Authoritative-Reason: HSTS on a 307 in DevTools is definitive proof that a cached HSTS entry, not your server, is doing the upgrade.

Diagnosing an HTTPS-forced dev host A decision tree that distinguishes a browser-cached dynamic HSTS policy from a preloaded static entry and routes each to its correct fix. Dev host forces HTTPS, no proceed button Query chrome://net-internals/#hsts read static_sts_domain static = false (dynamic) static = true (preloaded) Delete the dynamic entry net-internals / SiteSecurity file Cannot clear locally stop using preloaded suffix for dev Reload — http reachable verify 200, not 307 Use a .test / .localhost host off the preloaded registrable domain
Read static_sts_domain first: a dynamic entry is deletable locally, a preloaded static entry is not.

Edge Cases, Security Implications & Safe Rollback

Clearing the cached policy — Chrome. Open chrome://net-internals/#hsts, scroll to Delete domain security policies, enter the exact host (dev.example.com), and click Delete. This removes the dynamic entry immediately. Deleting the apex does not remove a subdomain entry — you must delete the exact host the browser stored. “Clear browsing data” does not reliably purge HSTS entries; the net-internals delete is the authoritative action.

Clearing the cached policy — Firefox. Firefox stores dynamic HSTS in a file named SiteSecurityServiceState.txt (older builds: SiteSecurityServiceState.bin) inside the profile directory. Close Firefox completely, then delete the matching line or the whole file:

# Firefox: profile paths vary by OS; find the active profile first
find ~/.mozilla/firefox -name 'SiteSecurityServiceState.txt'
# Close Firefox, then remove the entry (or the whole file to reset all HSTS state)
rm ~/.mozilla/firefox/<profile>.default-release/SiteSecurityServiceState.txt

A faster per-site route in Firefox: on the affected site’s error page or via the history panel, Forget About This Site drops its HSTS entry along with other stored state. Editing the file requires Firefox to be shut down, or it overwrites your change on exit.

Three traps dominate this scenario:

  1. Preloaded registrable domains cannot be un-stuck locally. If example.com is on the preload list, every browser treats every *.example.com host as HTTPS-only from the first request, before any header is seen — deleting the net-internals entry does nothing because static_sts_domain is true. The only fix is to develop on a host that is not under the preloaded suffix: use a reserved TLD such as myapp.localhost or myapp.test, which resolve locally and are never preloaded.
  2. includeSubDomains from staging poisons siblings. A staging box that erroneously sends includeSubDomains pins the entire apex in the browser of any developer who loads it once, including dev hosts they have never visited. This is why staging must send a self-scoped max-age with no includeSubDomains — see the Referrer-Policy and Permissions-Policy scoping discipline for the same “scope to exactly what you mean” principle applied to other headers.
  3. max-age does not clear on its own fast enough. Lowering max-age on the server never evicts an already-cached entry; it only shortens future ones. To actively revoke, serve Strict-Transport-Security: max-age=0 over HTTPS from the offending host and have affected clients revisit — but the reliable developer-side remedy remains the net-internals / profile-file deletion above.

The safe rollback for a leaked staging policy is: (1) serve max-age=0 over HTTPS from the staging host to stop re-pinning, (2) delete the cached entry in each affected browser, and (3) re-verify with a curl -sI that shows no includeSubDomains.

Cached HSTS versus cleared cache request flow A two-lane sequence comparing a browser with a cached HSTS entry that internally upgrades to HTTPS against a browser after cache deletion that reaches the http dev server. Before: HSTS entry cached After: entry deleted Browser http dev :3000 307 Internal Redirect (self) never reaches server connection refused (no TLS) Browser http dev :3000 GET http:// 200 OK dev server reachable again
With the entry cached the browser upgrades internally and never dials the server; after deletion the plain http request completes.

Conclusion

Keep HSTS out of everything a developer resolves locally: emit the enforcing max-age=63072000; includeSubDomains; preload value only from real production hosts, send a bounded self-scoped max-age=300 with no includeSubDomains from staging so a mistake evicts itself in minutes, and prefer .localhost or .test dev hostnames that no preload list can pin. When a machine is already locked, delete the exact host entry at chrome://net-internals/#hsts or in Firefox’s SiteSecurityServiceState.txt, confirm the request returns 200 rather than a 307 HSTS redirect, and only then move a policy toward its full production rollout.

Frequently Asked Questions

Why does bare localhost never get HSTS-locked but dev.example.com does? RFC 6797 requires HSTS Host names to be DNS-resolvable, and browsers explicitly exempt the literal localhost and 127.0.0.1. A named host under a registrable domain like dev.example.com is a normal DNS name, so it inherits includeSubDomains from its apex and gets pinned like any other subdomain.

How do I clear a cached HSTS entry in Chrome? Open chrome://net-internals/#hsts, scroll to Delete domain security policies, type the exact host (not the apex), and click Delete. Regular “Clear browsing data” does not reliably remove HSTS entries, so use the net-internals delete box.

Where does Firefox store HSTS state? In a file named SiteSecurityServiceState.txt (older builds .bin) in the active profile directory. Close Firefox, delete the matching line or the whole file to reset all HSTS state, or use Forget About This Site on the affected site for a per-host clear.

My domain is on the preload list — why can’t I clear it for my dev host? Preloaded entries are compiled into the browser, so static_sts_domain reports true and there is no per-host cache to delete. Develop on a hostname off the preloaded registrable domain — myapp.localhost or myapp.test both resolve locally and are never preloaded.

Is it safe to send includeSubDomains from staging? No. includeSubDomains on staging pins the entire apex in every developer’s browser, including dev hosts they have never opened. Staging should send a short, self-only max-age with no includeSubDomains and no preload.