HTTP Strict Transport Security sounds simple: send one response header and browsers stop using plain HTTP for your domain.
With AWS API Gateway, it’s a little messier.
The short version: API Gateway can return Strict-Transport-Security, but whether HSTS actually helps depends on how clients reach your API, whether you use a custom domain, and whether any HTTP endpoint still exists in front of it.
If you only remember one thing, remember this: HSTS protects browser traffic for hostnames, not APIs in the abstract. If your API is consumed by server-to-server clients, mobile apps, or SDKs, HSTS is mostly irrelevant. If your API is called from browser-based apps on a custom domain, then it absolutely matters.
What HSTS does for an API domain
HSTS tells browsers:
- only use HTTPS for this host
- optionally apply that rule to subdomains
- remember it for a period of time
A typical header looks like this:
Strict-Transport-Security: max-age=31536000; includeSubDomains
For preload eligibility, people often use:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
For API domains, I usually recommend starting with:
Strict-Transport-Security: max-age=300
Then increase to:
Strict-Transport-Security: max-age=31536000
Only add includeSubDomains if you’re very sure every subdomain is HTTPS-only. I’ve seen teams brick weird forgotten admin subdomains that way.
The API Gateway reality
API Gateway already serves traffic over HTTPS. That does not mean you’ve fully solved downgrade risk.
A few practical points:
-
The default execute-api hostname is already HTTPS-only
- Example:
https://abc123.execute-api.us-east-1.amazonaws.com - Browsers won’t reach it over HTTP anyway.
- HSTS on that hostname has limited value, though still technically valid.
- Example:
-
Custom domains are where HSTS matters
- Example:
https://api.example.com - If users or frontend code hit that hostname from a browser, HSTS helps prevent accidental or malicious HTTP use.
- Example:
-
HSTS only helps after the browser has seen the header once
- First visit is still vulnerable unless you use preloading.
- Preload is a serious commitment, so don’t throw it in casually.
-
If CloudFront, ALB, or another proxy sits in front of API Gateway, that layer should usually set HSTS
- Set security headers at the edge when possible.
- It’s cleaner and more consistent.
When you should set HSTS for API Gateway
You probably should if:
- your API is on a custom domain
- browser-based apps call it directly
- you want consistent security headers across your public endpoints
You probably don’t need to care much if:
- the API is only used by backend services
- clients never use browsers
- you only use the default
execute-apihostname internally
Still, many teams add it anyway for consistency. That’s fine.
Option 1: Set HSTS in API Gateway itself
This works well if API Gateway is the public edge for your custom domain.
You can add Strict-Transport-Security in several ways depending on the API type:
- REST API: integration response or Lambda response
- HTTP API: Lambda response or parameter mapping
- Lambda proxy integrations: easiest place is usually the Lambda function
Lambda proxy example for API Gateway
Here’s a simple Node.js Lambda returning HSTS:
export const handler = async (event) => {
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains"
},
body: JSON.stringify({
ok: true
})
};
};
Python version:
import json
def handler(event, context):
return {
"statusCode": 200,
"headers": {
"Content-Type": "application/json",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains"
},
"body": json.dumps({"ok": True})
}
That’s enough for many setups.
REST API non-proxy integration
For older REST APIs with non-proxy integrations, you can map the header in the integration response.
Example OpenAPI snippet:
paths:
/health:
get:
responses:
'200':
description: OK
headers:
Strict-Transport-Security:
schema:
type: string
x-amazon-apigateway-integration:
type: mock
requestTemplates:
application/json: '{"statusCode": 200}'
responses:
default:
statusCode: '200'
responseParameters:
method.response.header.Strict-Transport-Security: "'max-age=31536000; includeSubDomains'"
responseTemplates:
application/json: '{"ok":true}'
That works, but honestly, if you already use Lambda proxy integrations, setting headers in code is less painful.
Option 2: Set HSTS with API Gateway HTTP API response parameter mapping
HTTP APIs support response parameter mapping, which is cleaner than some of the old REST API response mapping machinery.
Example CloudFormation:
MyApi:
Type: AWS::ApiGatewayV2::Api
Properties:
Name: my-http-api
ProtocolType: HTTP
MyStage:
Type: AWS::ApiGatewayV2::Stage
Properties:
ApiId: !Ref MyApi
StageName: '$default'
AutoDeploy: true
DefaultRouteSettings:
DetailedMetricsEnabled: true
MyIntegration:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref MyApi
IntegrationType: AWS_PROXY
IntegrationUri: !GetAtt MyFunction.Arn
PayloadFormatVersion: '2.0'
MyRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref MyApi
RouteKey: 'GET /health'
Target: !Sub integrations/${MyIntegration}
In practice, I still prefer setting the header in Lambda unless I have a strong reason to centralize header policy elsewhere. It keeps behavior obvious during debugging.
Option 3: Put CloudFront in front and set HSTS there
This is usually my favorite pattern for public APIs on custom domains.
Why?
- one place to manage headers
- easier consistency across APIs and static sites
- edge-level control
- cleaner separation between application logic and transport security
With CloudFront, use a Response Headers Policy.
Example CloudFormation:
ApiSecurityHeadersPolicy:
Type: AWS::CloudFront::ResponseHeadersPolicy
Properties:
ResponseHeadersPolicyConfig:
Name: api-security-headers
SecurityHeadersConfig:
StrictTransportSecurity:
AccessControlMaxAgeSec: 31536000
IncludeSubdomains: true
Override: true
ApiDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
Origins:
- Id: api-gateway-origin
DomainName: abc123.execute-api.us-east-1.amazonaws.com
OriginPath: /prod
CustomOriginConfig:
OriginProtocolPolicy: https-only
DefaultCacheBehavior:
TargetOriginId: api-gateway-origin
ViewerProtocolPolicy: redirect-to-https
ResponseHeadersPolicyId: !Ref ApiSecurityHeadersPolicy
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
Aliases:
- api.example.com
A couple of opinions here:
ViewerProtocolPolicy: redirect-to-httpsis good, but it is not a replacement for HSTS.- If CloudFront sets HSTS with
Override: true, your backend can stop worrying about it.
HSTS and API Gateway custom domains
If you use an API Gateway custom domain such as api.example.com, that’s the hostname browsers learn HSTS for.
That means:
- the header must be returned on responses from
https://api.example.com - sending HSTS on
execute-api.amazonaws.comdoes not protectapi.example.com - each hostname has its own browser HSTS state
This trips people up all the time.
If your frontend calls api.example.com, test that exact host.
Common mistakes
1. Setting HSTS on error responses only sometimes
Browsers learn HSTS from any valid HTTPS response, not just 200s. You want it returned consistently.
If some routes include it and some don’t, rollout becomes unpredictable.
2. Using includeSubDomains too early
This can break:
- old staging hosts
- forgotten subdomains
- vendor-managed DNS entries
- weird mail or admin systems nobody documented
I start with the exact API host first.
3. Using preload because it looks “more secure”
Preloading is sticky. Once a domain is preloaded, backing out can take a while and can hurt real systems if you missed something.
For API-only subdomains like api.example.com, preload is often unnecessary.
4. Thinking HSTS helps non-browser API clients
It doesn’t, at least not directly. Curl, backend services, mobile HTTP stacks, and many SDKs do not use browser HSTS behavior.
You still need:
- TLS everywhere
- HTTP disabled or redirected at the edge
- certificate management
- sane client configuration
A safe rollout plan
I like this progression:
Step 1: Start tiny
Strict-Transport-Security: max-age=300
That’s 5 minutes. Low blast radius.
Step 2: Validate behavior
Check:
- custom domain works over HTTPS
- any HTTP entrypoint redirects or is unavailable
- no mixed hostname weirdness
- browser requests receive the header consistently
You can inspect headers manually:
curl -I https://api.example.com/health
Expected output:
HTTP/2 200
strict-transport-security: max-age=300
content-type: application/json
You can also run a quick scan with HeaderTest to confirm the header is present.
Step 3: Increase to one year
Strict-Transport-Security: max-age=31536000
Step 4: Consider includeSubDomains
Only after inventorying subdomains.
Testing with API Gateway and CloudFront
A few commands I actually use:
Check the custom domain:
curl -I https://api.example.com/health
Check the raw execute-api hostname:
curl -I https://abc123.execute-api.us-east-1.amazonaws.com/prod/health
Those can differ if CloudFront adds headers and the origin does not.
If you’re using CloudFront, test both. If the custom domain shows HSTS and the origin doesn’t, that may be perfectly fine.
AWS-specific gotchas
Edge-optimized vs regional custom domains
API Gateway custom domains can behave differently depending on whether you use edge-optimized or regional endpoints. From an HSTS perspective, the key question is still simple: which hostname does the browser hit?
That’s the hostname that must return the header.
CORS is separate
HSTS has nothing to do with CORS. I’ve seen people debug the wrong thing for hours because they changed security headers and expected cross-origin behavior to change.
It won’t.
Don’t inject headers in only one Lambda path
If your Lambda has multiple return paths, make header injection impossible to forget.
Bad:
if (!authorized) {
return { statusCode: 401, body: "nope" };
}
return {
statusCode: 200,
headers: { "Strict-Transport-Security": "max-age=31536000" },
body: "ok"
};
Better:
const baseHeaders = {
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Content-Type": "application/json"
};
function response(statusCode, body) {
return {
statusCode,
headers: baseHeaders,
body: JSON.stringify(body)
};
}
export const handler = async (event) => {
if (!event.headers?.authorization) {
return response(401, { error: "unauthorized" });
}
return response(200, { ok: true });
};
That pattern saves you from inconsistent header coverage.
Recommended setup
For most teams running browser-facing APIs on AWS, my recommendation is:
- use a custom domain
- terminate publicly through CloudFront if possible
- set HSTS in a CloudFront Response Headers Policy
- start with
max-age=300 - later move to
max-age=31536000 - hold off on
includeSubDomainsandpreloaduntil you’ve earned that confidence
If CloudFront is not in the picture, set the header directly in API Gateway or your Lambda responses and test the custom domain carefully.
HSTS for AWS API Gateway isn’t hard. The annoying part is knowing where to set it so it actually applies to the hostname browsers use. Once you get that right, the rest is just disciplined rollout.