I’ve seen this mistake a lot: a team puts a static site in S3, flips on static website hosting, maps a DNS record, and calls it done. The site is fast, cheap, and easy to deploy. It also can’t do HSTS correctly.
That matters because once you care about HTTPS, you usually want to enforce it hard. HSTS tells browsers: “Stop trying HTTP for this site. Only use HTTPS for a while.” Without it, users can still hit the site over plain HTTP first, get redirected, and stay exposed to downgrade and interception risks on that first request.
AWS S3 static website hosting is where people get tripped up. The website endpoint does not support HTTPS. No HTTPS means no Strict-Transport-Security header. No HSTS.
Here’s a real-world style case study of how this usually goes, what was broken, and how we fixed it.
The setup that looked fine in staging
A small marketing site lived in an S3 bucket:
- Bucket:
www.example.com - S3 static website hosting: enabled
- Route 53 alias: pointed to the S3 website endpoint
- Public bucket policy: allowed
s3:GetObject - Redirect from HTTP to HTTPS: done somewhere at the DNS/app layer? Kind of. In practice, it was inconsistent.
The team thought they were covered because typing https://www.example.com in the browser worked after some DNS and certificate workarounds. But the actual origin architecture was wrong for HSTS.
Before: the broken mental model
The assumption was:
- Host files in S3 website hosting
- Put a certificate on the domain
- Add HSTS
- Done
The problem is step 3. S3 website endpoints don’t let you control response headers the way you need, and they don’t serve the site over HTTPS directly.
If you hit the S3 website endpoint, it looks like this:
http://www-example-com.s3-website-us-east-1.amazonaws.com
That endpoint is HTTP-only. You cannot safely deploy HSTS from an HTTP-only origin.
What we observed
We ran a headers check and saw exactly what I’d expect:
- No
Strict-Transport-Security - HTTP still reachable
- Redirect behavior depended on which hostname and path you used
- Some asset URLs were hardcoded inconsistently
A quick scan with Headertest made the missing HSTS obvious.
You can also verify with curl:
curl -I http://www.example.com
curl -I https://www.example.com
Before the fix, the HTTPS response looked roughly like this:
HTTP/2 200
content-type: text/html
server: AmazonS3
x-cache: Miss from cloudfront
No HSTS header. That means browsers had no instruction to remember HTTPS-only access.
Why S3 static website hosting is the wrong layer for HSTS
This is the core issue:
- S3 website endpoints are for static website behavior, including index documents and custom error pages.
- They are not HTTPS-capable origins for browsers.
- HSTS only works when delivered over HTTPS.
So if you want HSTS on an S3-backed static site, you need a TLS-capable layer in front of S3. In AWS, that almost always means CloudFront.
And in practice, if you care about security, I’d go one step further: stop using the S3 website endpoint entirely and use the normal S3 bucket endpoint as a private origin behind CloudFront.
The fix: CloudFront in front of S3
We changed the architecture to this:
- S3 bucket stores static assets
- S3 public access disabled
- CloudFront distribution serves the site
- ACM certificate attached to CloudFront
- Route 53 alias points to CloudFront
- Response headers policy adds HSTS
- Viewer protocol policy redirects HTTP to HTTPS
That gets you real HTTPS delivery and a clean place to enforce headers.
After: the working architecture
1. Use S3 as a private origin
Instead of S3 static website hosting, use the bucket itself as the origin. Then restrict access so only CloudFront can read it.
That usually means:
- S3 Block Public Access: on
- CloudFront Origin Access Control (OAC): enabled
You can set this up in AWS using CloudFront and an S3 bucket policy. Official docs:
Example bucket policy allowing only the CloudFront distribution:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipalReadOnly",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::www.example.com/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E123ABC456DEF"
}
}
}
]
}
2. Force HTTPS at CloudFront
Set the behavior’s Viewer Protocol Policy to:
Redirect HTTP to HTTPS
That handles the upgrade for users who still type http://.
3. Add HSTS at CloudFront
This is the piece S3 couldn’t do properly for us.
CloudFront supports Response Headers Policies. You can either use a managed policy or define your own. I usually prefer defining it explicitly so there’s no ambiguity.
A solid starting point:
Strict-Transport-Security: max-age=31536000; includeSubDomains
If you are absolutely sure every subdomain is HTTPS-only and you want preload eligibility, then use:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Be careful with includeSubDomains and especially preload. I’ve seen teams brick internal or forgotten subdomains because they got too enthusiastic.
Example CloudFront response headers policy in Terraform:
resource "aws_cloudfront_response_headers_policy" "security_headers" {
name = "security-headers-policy"
security_headers_config {
strict_transport_security {
access_control_max_age_sec = 31536000
include_subdomains = true
preload = false
override = true
}
content_type_options {
override = true
}
frame_options {
frame_option = "DENY"
override = true
}
referrer_policy {
referrer_policy = "strict-origin-when-cross-origin"
override = true
}
xss_protection {
protection = true
mode_block = true
override = true
}
}
}
Then attach it to the CloudFront cache behavior:
default_cache_behavior {
target_origin_id = "s3-origin"
viewer_protocol_policy = "redirect-to-https"
response_headers_policy_id = aws_cloudfront_response_headers_policy.security_headers.id
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
}
Before and after: actual response difference
Before
curl -I https://www.example.com
Response:
HTTP/2 200
content-type: text/html
server: AmazonS3
After
curl -I https://www.example.com
Response:
HTTP/2 200
content-type: text/html
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
server: CloudFront
And for HTTP:
curl -I http://www.example.com
Response:
HTTP/1.1 301 Moved Permanently
location: https://www.example.com/
That’s the shape you want.
The rollout mistake I try to avoid
A lot of people jump straight to a one-year HSTS policy. I get why. It’s the recommended end state for many sites.
I still prefer a staged rollout unless the environment is very simple.
A sane progression looks like this:
max-age=300max-age=86400max-age=31536000; includeSubDomains- Consider
preloadonly after you’ve audited everything
Why? Because HSTS is sticky in browsers. If you push a bad policy, users keep it until it expires. That’s annoying at best and an outage at worst.
One more gotcha: redirects at the wrong layer
Some teams keep S3 static website hosting because they want its index/error document behavior. That can work for basic sites, but if you’re serious about security, I’d rather move those concerns to CloudFront or build the site so direct object delivery is enough.
Mixing:
- S3 website redirects
- CloudFront redirects
- app-level redirects
- DNS tricks
usually creates weird edge cases. I’ve debugged enough redirect loops and missing headers to be pretty opinionated here: keep TLS and security policy enforcement at CloudFront, keep storage in S3, and keep the origin private.
What changed for the team
After the migration:
- HSTS was present on every HTTPS response
- HTTP requests were consistently redirected at the edge
- S3 was no longer public
- Security scans stopped flagging missing transport enforcement
- The setup was easier to reason about
That last point matters more than people admit. A simpler architecture is easier to secure.
The practical takeaway
If your static site is using the S3 website endpoint, you do not have a proper place to enforce HSTS. The fix is not to fight S3 harder. The fix is to put CloudFront in front, terminate TLS there, add the HSTS header there, and lock down the bucket behind it.
That’s the real before-and-after:
Before: “S3 static hosting works, so we assumed HTTPS security headers would too.”
After: “CloudFront handles HTTPS and headers; S3 just stores files.”
That division of responsibility is what makes HSTS on AWS static sites actually work.
For verification, use curl, browser dev tools, and a header scanner like Headertest. For AWS specifics, stick to the official docs:
If I inherited an S3 static site today and HSTS was a requirement, I wouldn’t try to patch around the limitation. I’d move the site behind CloudFront first. That’s the clean fix.