If your domain is on the HSTS preload list and you need it out, the process is simple on paper and annoyingly slow in practice.
The hard part is not the form submission. The hard part is understanding what browsers will keep doing after you change your headers, and making sure you do not trap users behind a broken HTTPS setup while preload removal works its way through browser releases.
This guide is the practical version: what to change, what to submit, and what to expect.
What “HSTS preload” actually means
Normal HSTS is learned by the browser after it visits your site and sees a Strict-Transport-Security header.
Preloaded HSTS is different. Your domain is baked into browser lists ahead of time. Browsers already know to force HTTPS before the first request ever happens.
If your domain is preloaded, users can get forced onto HTTPS even when:
- they type
http://example.com - they click an old HTTP link
- they have never visited your site before
- your server is currently misconfigured
That is why preload is useful when your HTTPS setup is stable. It is also why removing it can be painful.
When you should remove a domain from preload
Usually I would avoid this unless you really need it. Good reasons include:
- you can no longer guarantee HTTPS for the whole domain
- some subdomains must remain available over HTTP
- you accidentally preloaded a parent domain that covers legacy subdomains
- you are splitting infrastructure and cannot support
includeSubDomains - certificate or DNS constraints make HTTPS impossible on part of the namespace
Bad reason:
- “We want flexibility later”
If your site can stay fully HTTPS, keep preload. Rolling back preload weakens transport security and takes time anyway.
The requirement for removal
To request removal, your domain must stop advertising preload and must send an HSTS header with a low max-age, typically max-age=0.
That means your old header like this:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
needs to become this:
Strict-Transport-Security: max-age=0
That tells browsers: forget the HSTS policy you learned from headers.
It does not instantly override the preload list already shipped in browsers. That is the part many teams miss.
The safe removal sequence
This is the sequence I recommend:
- Remove
preload - Remove
includeSubDomains - Set
max-age=0 - Deploy everywhere on HTTPS responses for the domain
- Verify the live header
- Submit the domain for preload removal
- Wait for browser vendors to ship updated preload lists
If you only update one edge node, one region, or one app tier, you are asking for a messy partial rollout.
Header examples you can copy-paste
Nginx
If you currently have something like:
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
change it to:
add_header Strict-Transport-Security "max-age=0" always;
Full server block example:
server {
listen 443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/ssl/example/fullchain.pem;
ssl_certificate_key /etc/ssl/example/privkey.pem;
add_header Strict-Transport-Security "max-age=0" always;
location / {
proxy_pass http://app_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
}
Reload Nginx:
sudo nginx -t && sudo systemctl reload nginx
Apache
Old header:
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
New header:
Header always set Strict-Transport-Security "max-age=0"
VirtualHost example:
<VirtualHost *:443>
ServerName example.com
ServerAlias www.example.com
SSLEngine on
SSLCertificateFile /etc/ssl/example/fullchain.pem
SSLCertificateKeyFile /etc/ssl/example/privkey.pem
Header always set Strict-Transport-Security "max-age=0"
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
</VirtualHost>
Reload Apache:
sudo apachectl configtest && sudo systemctl reload apache2
IIS web.config
<configuration>
<system.webServer>
<httpProtocol>
<customHeaders>
<remove name="Strict-Transport-Security" />
<add name="Strict-Transport-Security" value="max-age=0" />
</customHeaders>
</httpProtocol>
</system.webServer>
</configuration>
Express.js
If you use Helmet, do not keep the preload options around.
Before:
app.use(
helmet.hsts({
maxAge: 63072000,
includeSubDomains: true,
preload: true
})
);
After:
app.use(
helmet.hsts({
maxAge: 0
})
);
Or set the header directly:
app.use((req, res, next) => {
res.setHeader("Strict-Transport-Security", "max-age=0");
next();
});
Node.js native HTTP server
const https = require("https");
const fs = require("fs");
https.createServer(
{
key: fs.readFileSync("privkey.pem"),
cert: fs.readFileSync("fullchain.pem")
},
(req, res) => {
res.setHeader("Strict-Transport-Security", "max-age=0");
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("ok");
}
).listen(443);
PHP
<?php
header("Strict-Transport-Security: max-age=0");
echo "ok";
How to verify the header
Use curl against the HTTPS endpoint:
curl -I https://example.com
You want to see:
Strict-Transport-Security: max-age=0
You do not want to see any of these anymore:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Check multiple hostnames too:
curl -I https://example.com
curl -I https://www.example.com
curl -I https://sub.example.com
If you want a quick visual check for headers, run a scan with Headertest. I still prefer curl for final verification because it shows exactly what the server returned.
Submit the removal request
Once the live header is correct, submit your domain for removal through the official HSTS preload service documentation and submission flow:
The preload service checks whether your domain qualifies for removal. If your site still serves a preload-eligible header anywhere relevant, it will reject the request.
What happens after submission
This is where people get frustrated.
Removal is not instant because preload is distributed through browser updates. Even after your removal request is accepted:
- Chrome-based browsers need updated preload data to ship
- users need to update their browsers
- some users will keep old browser versions for a while
- existing HSTS state learned from past visits may still stick until browsers expire or clear it
So yes, you can do everything right and still have users forced to HTTPS for some time.
That is normal.
Browser behavior you should expect
There are really three separate states in play:
1. Preloaded state
The browser has your domain in its baked-in list.
2. Dynamically learned HSTS state
The browser visited your site before and cached your old HSTS header.
3. Current live server header
What your server returns today.
Changing the live header to max-age=0 helps with dynamic HSTS state after a successful HTTPS visit. It does not erase old preload entries from already shipped browser binaries.
That distinction matters a lot during incident response.
Common mistakes
Only changing the apex domain
If www.example.com or another canonical hostname still serves the old header, removal can fail or users can keep learning HSTS dynamically.
Check every hostname users actually hit.
Forgetting CDN or load balancer header injection
I have seen teams update the app and forget that Cloudflare, Fastly, Nginx ingress, ALB rules, or a reverse proxy is still injecting the old header.
Search every layer.
Keeping includeSubDomains
If you are trying to stop HSTS from affecting subdomains, includeSubDomains has to go.
This header is wrong for removal:
Strict-Transport-Security: max-age=0; includeSubDomains
Use:
Strict-Transport-Security: max-age=0
Testing with a browser and assuming the server is wrong
Sometimes the server is fixed but your browser has cached HSTS state or still has a preloaded entry. Test with curl, and test with a fresh browser profile before blaming the config.
How to check your current preload status
The official preload service is the source of truth:
You can also inspect your live headers directly:
curl -sI https://example.com | grep -i strict-transport-security
And for subdomains:
for host in example.com www.example.com api.example.com; do
echo "=== $host ==="
curl -sI "https://$host" | grep -i strict-transport-security
done
Operational advice from experience
If you are removing preload because HTTPS is broken, do not assume removal will save you quickly. It will not.
Your fastest path is usually:
- restore valid HTTPS
- serve
max-age=0 - submit removal
- keep HTTPS working during the transition
That gives both preloaded users and dynamically cached users the best chance to recover cleanly.
If you are removing preload because of one troublesome legacy subdomain, think hard before changing the whole parent domain. Sometimes the real fix is to migrate or retire the legacy service instead of weakening transport security for everything.
Quick checklist
Copy this into your ticket:
[ ] Remove `preload` from Strict-Transport-Security
[ ] Remove `includeSubDomains`
[ ] Set header to `Strict-Transport-Security: max-age=0`
[ ] Deploy on all HTTPS responses and all relevant hosts
[ ] Verify with curl
[ ] Check CDN, proxy, ingress, and app layers for duplicate headers
[ ] Submit official preload removal request
[ ] Expect browser rollout delays
[ ] Keep HTTPS stable during the waiting period
If you want the short version, it is this: serve Strict-Transport-Security: max-age=0, make sure that is true everywhere, then submit the official removal request and wait longer than you want. That is the real preload removal workflow.