HSTS looks simple: send Strict-Transport-Security, force browsers onto HTTPS, move on.
Then AWS gets involved.
I’ve seen teams enable HTTPS on an ALB or CloudFront, add a redirect somewhere, and assume they’re done. They aren’t. HSTS in AWS usually breaks because the setup spans multiple layers: browser, CloudFront, load balancer, origin, app, and sometimes a second redirect hiding in the middle.
Here are the mistakes I see most often, and how to fix them.
Mistake #1: Sending HSTS over HTTP redirects
A classic misunderstanding: “We redirect HTTP to HTTPS, so I’ll add HSTS there.”
Browsers ignore HSTS on plain HTTP responses. The header only counts when it arrives over a valid HTTPS connection.
This does not help:
HTTP/1.1 301 Moved Permanently
Location: https://example.com/
Strict-Transport-Security: max-age=31536000
The browser throws that header away.
Fix
Serve HSTS only on HTTPS responses. Keep the HTTP side focused on one job: redirect to HTTPS.
If you use CloudFront, configure Viewer Protocol Policy to redirect HTTP to HTTPS. Then attach the HSTS header to the HTTPS response, usually with a Response Headers Policy.
If you use an ALB, create an HTTP listener on port 80 that redirects to 443, and make sure the app or reverse proxy behind 443 sends HSTS.
For ALB HTTP-to-HTTPS redirect:
{
"Type": "redirect",
"RedirectConfig": {
"Protocol": "HTTPS",
"Port": "443",
"Host": "#{host}",
"Path": "/#{path}",
"Query": "#{query}",
"StatusCode": "HTTP_301"
}
}
Then on the HTTPS side, send:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Mistake #2: Setting HSTS at the app, but CloudFront strips or overrides it
A lot of teams set HSTS in Nginx, Apache, or the app framework, then put CloudFront in front and forget to verify what viewers actually receive.
CloudFront can override headers if you attach a Response Headers Policy. That can be helpful, but it also means you may think your origin is sending one value while viewers get another.
I prefer to pick one canonical place for HSTS:
- CloudFront if it’s the public edge for the site
- Origin/app if there’s no CDN in front
Don’t split ownership unless you enjoy debugging header mismatches.
Fix
If CloudFront is public-facing, set HSTS in a Response Headers Policy and treat that as the source of truth.
Example Terraform:
resource "aws_cloudfront_response_headers_policy" "security_headers" {
name = "security-headers"
security_headers_config {
strict_transport_security {
access_control_max_age_sec = 31536000
include_subdomains = true
preload = false
override = true
}
}
}
Then attach it to the behavior:
default_cache_behavior {
target_origin_id = "app-origin"
viewer_protocol_policy = "redirect-to-https"
response_headers_policy_id = aws_cloudfront_response_headers_policy.security_headers.id
}
After that, test the actual edge response, not just the origin. A free scan like HeaderTest makes this obvious fast.
Mistake #3: Forgetting that ELB type matters
People say “ELB” when they really mean ALB, NLB, or old Classic Load Balancer. HSTS behavior depends on which one you’re using.
- ALB can terminate TLS and do redirects, but it does not natively inject HSTS headers into responses like a web server would.
- NLB works at a lower level. It won’t solve header management for you.
- Classic ELB is legacy territory. If you’re still there, that’s probably the bigger problem.
Fix
Know where TLS terminates and where headers are added.
Typical patterns:
-
CloudFront -> ALB -> app
- Redirect at CloudFront or ALB
- Set HSTS at CloudFront, or at the app if CloudFront isn’t overriding
-
ALB -> app
- Redirect at ALB
- Set HSTS in app or reverse proxy
-
NLB -> app
- Handle redirects and HSTS entirely at the app or proxy layer
If you’re expecting ALB alone to “turn on HSTS,” that’s the mistake.
Mistake #4: Using includeSubDomains before the subdomains are ready
This one hurts. Teams copy a recommended header:
Strict-Transport-Security: max-age=31536000; includeSubDomains
That’s a good header only if every subdomain is HTTPS-clean.
If dev.example.com, old-admin.example.com, or m.example.com still serves HTTP, browsers that have cached HSTS for example.com will refuse to connect insecurely. That’s the point of HSTS. It’s also how you lock yourself out of forgotten subdomains.
Fix
Inventory your subdomains before enabling includeSubDomains.
My rollout order usually looks like this:
- Start with:
Strict-Transport-Security: max-age=300
- Verify the main domain works cleanly
- Audit subdomains
- Move to:
Strict-Transport-Security: max-age=31536000; includeSubDomains
If your AWS setup includes multiple CloudFront distributions or ALBs for subdomains, verify each one has a sane HTTPS config and certificate coverage.
Mistake #5: Going for preload too early
Preload is not a nice badge. It’s a commitment.
When you add:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
you’re saying:
- HTTPS is permanent
- all subdomains support HTTPS
- you’re okay being hardcoded into browser preload lists
That’s hard to undo. Not impossible, but definitely annoying and slow.
Fix
Don’t preload until the domain has been stable on HTTPS for a while and every subdomain is under control.
For preload eligibility, you generally need:
max-ageat least31536000includeSubDomainspreload- valid HTTPS everywhere
For many AWS shops, the blocker is stray subdomains pointing at old S3 website endpoints, forgotten ELBs, or dead CloudFront distributions. Clean those up first.
Mistake #6: Redirect loops between CloudFront, ALB, and the origin
I’ve debugged this more times than I’d like.
Example setup:
- CloudFront redirects HTTP to HTTPS
- ALB redirects HTTP to HTTPS
- App also redirects based on
X-Forwarded-Proto - One layer misreads the original scheme
- Users get loops or weird 301 chains
HSTS won’t fix this. It just makes the browser prefer HTTPS after the first successful secure visit.
Fix
Pick one primary redirect layer. My preference:
- CloudFront handles viewer HTTP-to-HTTPS redirects
- Origin assumes traffic from CloudFront is already normalized
- App only redirects if it must, and correctly trusts proxy headers
For Express behind ALB/CloudFront:
app.set('trust proxy', true);
app.use((req, res, next) => {
if (req.secure) return next();
return res.redirect(301, `https://${req.headers.host}${req.originalUrl}`);
});
If this app sits behind CloudFront with redirect-to-https, you may not need the app redirect at all. Fewer moving parts is usually better.
Mistake #7: Not sending HSTS on all HTTPS responses
A surprising number of setups only send HSTS on the homepage.
That’s enough for many browsers to cache it, but it’s sloppy. If some paths bypass your normal app stack — static assets, error pages, custom origins, S3-backed behaviors in CloudFront — you can end up with inconsistent behavior.
Fix
Make sure HSTS is attached at a layer that covers all HTTPS responses for that hostname.
CloudFront Response Headers Policies are good for this because they apply at the behavior level. If different behaviors serve different origins, make sure each relevant behavior gets the same security headers policy.
If you handle HSTS in Nginx, use always so error responses get it too:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Without always, some non-200 responses may skip the header.
Mistake #8: Testing the origin instead of the public hostname
This is how bad configs survive for months.
The app team curls the ALB DNS name. The infra team checks the origin directly. Nobody checks https://www.example.com through CloudFront with the real cert, real redirects, and real cache behavior.
HSTS is a browser-facing control. Test the browser-facing endpoint.
Fix
Verify at least these:
curl -I http://example.com
curl -I https://example.com
curl -I https://www.example.com
You want to see:
- HTTP returns a clean redirect to HTTPS
- HTTPS returns
Strict-Transport-Security - the final public host is the one actually protected
If you use multiple hostnames on one distribution or ALB, test each one.
Mistake #9: Setting a huge max-age on day one
A one-year HSTS policy is fine when you know the setup is stable. It’s reckless when you’re still discovering edge cases.
Browsers cache HSTS aggressively. If you ship a bad policy and break access to a hostname, users don’t magically recover when you regret it five minutes later.
Fix
Roll out in stages.
I like this progression:
Strict-Transport-Security: max-age=300
Then:
Strict-Transport-Security: max-age=86400
Then:
Strict-Transport-Security: max-age=31536000; includeSubDomains
That gives you room to catch bad redirects, mixed host coverage, and forgotten subdomains before the policy becomes sticky.
My recommended AWS pattern
For most teams running a public site on AWS, the cleanest setup is:
- CloudFront as the public edge
- Viewer Protocol Policy:
redirect-to-https - Response Headers Policy: set HSTS there
- ALB behind CloudFront for app traffic
- App doesn’t fight the edge with duplicate redirect logic unless necessary
That gives you one visible place for protocol enforcement and one visible place for HSTS.
Use the app layer for app problems. Use the edge for edge policy.
That separation keeps HSTS boring, which is exactly what you want from a security control.