Fixing Surprising HSTS Behavior from Cloudflare and Netlify
Mozilla Observatory said my website was missing HTTP Strict-Transport-Security
(HSTS) headers, which surprised me since I went out of my way to add them to my Netlify server configuration (netlify.toml
). Eventually I figured out my CDN (Cloudflare) quietly modifies the response headers that get passed from my origin server to the visitor.
If you’re running into the same problem:
- Open your site’s Cloudflare dashboard
- SSL/TLS -> Edge Certificates
- Click “Change HSTS Settings”
- Check the box that says “I Understand” and click “Next”
- Toggle “Enable HSTS (Strict-Transport-Security)”
- Set “Max Age Header (max-age)” to “12 months”
- Click “Save”
Now your site will set a restrictive HSTS policy for visitors.
Those are the “grip it and rip it” instructions, where I glossed over a couple of things:
- That part about acknowledging you understand the risks of enabling HSTS is worth pondering; if your site’s HTTPS implementation breaks somehow, your website will be inaccessible — possibly for a long time
- If your HTTPS implementation breaks and your server previously told visitors to only access your site via HTTPS, that max-age value defines the expiration date of that agreement. A shorter max-age value means your website gets out of HSTS jail faster
How I Figured All That Out
My troubleshooting journey for this issue had a couple of plot twists, which I’ll explain in case someone else finds themselves on the same path.
My website is hosted on Netlify, with Cloudflare’s CDN in front of it. My netlify.toml
file has a line in it: Strict-Transport-Security = "max-age=63072000"
Running curl
to get the HTTP headers for ianjmacintosh.com
showed strict-transport-security: max-age=0
Weird. It’s not just like the header is missing (which I’d expect if I’d made a simple syntax error in the TOML file or referred to the wrong property), but it’s displaying a totally different value which I’ve never given it. At this point, it wasn’t clear to me if Netlify was changing stuff or Cloudflare, since both handle the request.
So I decided to remove Cloudflare from the equation and access my website directly from Netlify, using its netlify.app
URL. Every Netlify site has one of these in addition to whatever custom domain you host from.
Running curl
to get the HTTP headers from my netlify.app
URL returned strict-transport-security: max-age=31536000; includeSubDomains; preload
That’s even weirder. It’s my app returning another completely different HSTS header that I’ve never specified anywhere. After some searching, I found Netlify appends this header to all requests handled through their netlify.app
domains, as documented exclusively in this message board post: https://answers.netlify.com/t/security-headers-adding-includesubdomains-and-preload-to-strict-transport-security-header-to-sites-with-default-domain-name/19706
I decided to focus on Cloudflare to set my HSTS headers (maybe by setting a transform rule on the HTTP headers) but stumbled across the HSTS settings panel I pointed out earlier. After changing that, I got myself an HSTS header that made sense.
Good enough! Although I’d still like to learn how to configure Cloudflare to simply pass the values of my HSTS headers along from the origin server.
Extra Credit
I was unsatisfied with not knowing if my Netlify server actually used the configuration I gave it, so I spent a little extra time to request my website directly from Netlify using my ianjmacintosh.com
domain.
First I got the IP address my app’s netlify.app
domain points to (by running dig ianjmacintosh-burgeoning-wombat.netlify.app
from my terminal) and added a new line in my /etc/hosts
file to resolve ianjmacintosh.com
to that IP address. The IP address dig gave me is an IPv4 address, so I’ll need to be sure to request my site using IPv4.
Next, I requested my site. I ran curl -4Is https://www.ianjmacintosh.com
- The
-4
flag forces IPv4 so I can use the IPv4 address in myhosts
file — otherwise the DNS request would resolve to the “real” Cloudflare IPv6 address - The
-I
flag instructs curl to only fetch the headers (I don’t need a giant source dump of my homepage printed to stdout) - The
-s
flag activates “silent mode,” which was unnecessary but suppresses curl’s progress meter and any error messages.
The response confirmed that Netlify honored my settings; curl displayed strict-transport-security: max-age=63072000
But in the end, it’s a moot point. There’s no way to instruct Cloudflare to pass the header along as-is and allow the origin server to manage its own HSTS configuration.
Closing Thoughts
This whole journey impressed upon me how useful it is to maintain a side project. It also reminded me how important audit tools can be in the on-going effort to keep my knowledge up-to-date — if Mozilla Observatory hadn’t prompted me to use HSTS, I wouldn’t have learned about any of this. Most importantly, it’s way better to get a hands-on understanding about how Netlify and Cloudflare handle HSTS in a low stakes environment instead of grasping at straws in a Network Operations Center during an outage.