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:

  1. 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.
  2. 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.
  3. 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.
  4. 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-api hostname 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-https is 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.com does not protect api.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.

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 includeSubDomains and preload until 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.