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
max-age=63072000— the enforcement lifetime in seconds (two years). The browser stores this policy per-host and refreshes the countdown on every HTTPS response, so a policy set once can outlive the project it was set for.includeSubDomains— the trap. This valueless flag applies the policy to every host below the issuing host, including ones the browser has never visited. Ifexample.comsends it, the policy is asserted fordev.example.com,app.local.example.com, and any*.example.comyou point at127.0.0.1in/etc/hosts.preload— a commitment flag meaning “I want this baked into the browser binary.” Once a suffix is preloaded, every host under it — including a developer’smyapp.dev.example.com— is HTTPS-only from the first byte, with no cache to clear.
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.
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.
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:
- Preloaded registrable domains cannot be un-stuck locally. If
example.comis on the preload list, every browser treats every*.example.comhost as HTTPS-only from the first request, before any header is seen — deleting the net-internals entry does nothing becausestatic_sts_domainistrue. The only fix is to develop on a host that is not under the preloaded suffix: use a reserved TLD such asmyapp.localhostormyapp.test, which resolve locally and are never preloaded. includeSubDomainsfrom staging poisons siblings. A staging box that erroneously sendsincludeSubDomainspins 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-scopedmax-agewith noincludeSubDomains— see the Referrer-Policy and Permissions-Policy scoping discipline for the same “scope to exactly what you mean” principle applied to other headers.- max-age does not clear on its own fast enough. Lowering
max-ageon the server never evicts an already-cached entry; it only shortens future ones. To actively revoke, serveStrict-Transport-Security: max-age=0over 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.
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.