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:

  1. CloudFront -> ALB -> app

    • Redirect at CloudFront or ALB
    • Set HSTS at CloudFront, or at the app if CloudFront isn’t overriding
  2. ALB -> app

    • Redirect at ALB
    • Set HSTS in app or reverse proxy
  3. 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:

  1. Start with:
   Strict-Transport-Security: max-age=300
  1. Verify the main domain works cleanly
  2. Audit subdomains
  3. 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-age at least 31536000
  • includeSubDomains
  • preload
  • 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.

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.