HSTS preload on Vercel: why the apex redirect ignored my header
I added Strict-Transport-Security: max-age=63072000; includeSubDomains; preload to headers() in next.config.js on this site, and www served it. The apex, mohamedgado.com, still answered with Vercel's default max-age=6307
I added Strict-Transport-Security: max-age=63072000; includeSubDomains; preload to headers() in next.config.js on this site, and www served it. The apex, mohamedgado.com, still answered with Vercel's default max-age=63072000 and nothing else. hstspreload.org checks the apex, so the domain could not be submitted. The cause was the apex redirect. It was set in Vercel's Domains settings, and a domain-level redirect is answered before your project runs, so headers() never touches it. The fix was to move that redirect into redirects() in next.config.js and set the apex domain to serve Production.
Why did http://mohamedgado.com take two redirects?
This started as a crawl finding. Typing http://mohamedgado.com took two hops:
http://mohamedgado.com/ 308 -> https://mohamedgado.com/
https://mohamedgado.com/ 308 -> https://www.mohamedgado.com/
Vercel upgrades every HTTP request to HTTPS on the same host first, with a 308. Their encryption docs say this redirect is an industry standard and cannot be disabled. Then the apex redirects to www. You can't merge the two hops, and you shouldn't try: the HSTS preload list requires that a site listening on port 80 redirects to HTTPS on the same host before it goes anywhere else.
What you can remove is the HTTP request itself. A browser that has the domain on its built-in HSTS preload list never sends http:// at all. It rewrites the URL to https:// before the first byte leaves the machine. The Vercel docs make the same point in their HSTS note: a site on the list is reached over HTTPS right away instead of following the redirect the CDN issues.
What does Vercel send for HSTS by default?
The docs give two defaults. The .vercel.app deployment URLs get the full preload header, and the whole vercel.app domain is already preloaded. Custom domains get HSTS for that one hostname only:
Strict-Transport-Security: max-age=63072000;
hstspreload.org rejects that. Its API returned two errors for my domain: No includeSubDomains directive and No preload directive. The docs also say you can change the header with custom response headers in your project, so I did.
Setting the header in next.config.js
The header goes in the rule that covers every route, next to the rest of the security headers:
// next.config.js
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
// Content-Security-Policy, X-Content-Type-Options, ...
],
},
]
}
Before you add includeSubDomains, list every hostname in your DNS that serves web traffic. The directive tells browsers to refuse plain HTTP on all of them, including names you forgot about. I read the records at my registrar. There were five web hosts: the apex, www, and short subdomains for my apps Masareef, Rafiq and Nafis. All were on Vercel and all on HTTPS. The rest were TXT and MX records for email, which HSTS doesn't affect. One HTTP-only subdomain, such as an email click-tracking CNAME, would have broken the moment a browser saw the new header.
After the deploy, www was correct:
$ curl -sI https://www.mohamedgado.com | grep -i strict
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
$ curl -sI https://mohamedgado.com | grep -i strict
Strict-Transport-Security: max-age=63072000
The apex had not changed.
Why the apex redirect ignored headers()
In the Vercel dashboard, the apex was set to "Redirect to Another Domain", 308, to www. That is the setup Vercel suggests when you add a domain, and it works. But the redirect is answered at the domain level, before any request reaches your deployment's routing. Your headers(), redirects() and middleware never run for it, so the response carries Vercel's default HSTS and nothing from your config.
Preload checks the apex, because the list entry is the registrable domain. So the header has to be on the response the apex actually sends, and here that response is a redirect.
Moving the apex redirect into next.config.js
The fix is to let the deployment answer the apex and redirect it in code, where headers() applies. I added a host-guarded rule at the top of redirects():
// next.config.js
async redirects() {
return [
{
source: '/:path*',
has: [{ type: 'host', value: 'mohamedgado.com' }],
destination: 'https://www.mohamedgado.com/:path*',
permanent: true, // 308, same as the domain-level redirect
},
// ...the rest
]
}
One thing to check before you trust that has rule: an unanchored pattern mohamedgado.com would also match www.mohamedgado.com and loop forever. Next.js compiles has values as new RegExp(`^${value}$`) (you can read it in prepare-destination.js), so the match is exact.
Then the order of operations matters:
- Deploy the code redirect first. It does nothing yet, because the domain-level redirect still answers the apex before the deployment sees it.
- In Vercel, open Domains, edit the apex, and switch it from "Redirect to Another Domain" to "Connect to an environment: Production".
Done in that order, the apex redirects to www the whole time. Done the other way round, there is a window where the apex serves the whole site as a duplicate of www.
How do I check it worked?
The apex now answers through the deployment, so it carries the full header and keeps the path and query:
$ curl -sI "https://mohamedgado.com/projects/masareef?x=1"
HTTP/1.1 308 Permanent Redirect
Location: https://www.mohamedgado.com/projects/masareef?x=1
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
hstspreload.org has an API that runs the same checks as its form:
$ curl -s "https://hstspreload.org/api/v2/preloadable?domain=mohamedgado.com"
{ "errors": [], "warnings": [] }
I also pinned both rules with tests that read next.config.js. One asserts that the every-route header has a max-age of at least a year plus includeSubDomains and preload. The other asserts that the apex has exactly one 308 to www. A later edit can't quietly drop either one.
Should you submit to the preload list?
Getting the domain eligible was the useful part. The header now reaches every host, and the apex takes one redirect, not two. Submitting is a separate decision, and for a portfolio I didn't submit.
The list does nothing for search rankings. Google already crawls the HTTPS URLs. A returning visitor's browser already remembers the HSTS header for two years after the first visit. Most browsers now try HTTPS first when someone types a bare domain. What preload adds is protection on the very first visit from someone who types http:// on a network that strips TLS. The cost is a commitment: every future subdomain has to serve HTTPS, and getting off the list takes months.
If you run a login or payments on the domain, submit. If not, the header alone covers almost everything.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.