CORS on AWS API Gateway HTTP APIs looks simple right up until your browser starts throwing No 'Access-Control-Allow-Origin' header and your backend logs show everything is “working fine.”

I’ve hit this enough times that I now treat CORS as part browser contract, part API Gateway feature, and part trap.

This guide is about API Gateway HTTP APIs specifically, not the older REST API product. The behavior is different enough that mixing them up causes bad advice and wasted hours.

What CORS is doing here

Browsers block frontend JavaScript from reading cross-origin responses unless the server opts in with CORS headers.

If your frontend runs on:

https://app.example.com

and it calls:

https://api.example.com

that’s cross-origin. The browser checks whether the API response allows it.

For simple requests, the browser mainly cares about headers like:

Access-Control-Allow-Origin: https://app.example.com

For non-simple requests, the browser first sends a preflight request:

OPTIONS /users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

Your API has to answer that preflight correctly before the real request is sent.

What makes HTTP APIs different

AWS API Gateway has two major API types:

  • REST APIs
  • HTTP APIs

For HTTP APIs, AWS gives you a built-in CORS configuration block. That’s the happy path. You usually configure CORS at the gateway level and let API Gateway generate the right headers for preflight and responses.

That’s much cleaner than manually handling OPTIONS in every Lambda.

The catch: if you partly configure CORS in API Gateway and partly in your backend, you can get weird results or duplicate/conflicting headers.

My rule is simple:

  • If you use API Gateway HTTP API CORS config, let API Gateway own CORS.
  • Only do dynamic/manual CORS in Lambda if you truly need origin-by-origin runtime logic.

A real-world CORS header example

A good way to sanity-check your own API is to look at real production APIs. GitHub’s API sends:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset

That’s a practical example of two things:

  1. Access-Control-Allow-Origin decides who can read the response.
  2. Access-Control-Expose-Headers decides which non-simple response headers frontend JavaScript can access.

If your frontend needs to read ETag or rate limit headers, you need Access-Control-Expose-Headers.

The basic HTTP API CORS config

Here’s a straightforward HTTP API definition in CloudFormation:

Resources:
  HttpApi:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: cors-demo-api
      ProtocolType: HTTP
      CorsConfiguration:
        AllowOrigins:
          - https://app.example.com
        AllowMethods:
          - GET
          - POST
          - OPTIONS
        AllowHeaders:
          - content-type
          - authorization
        ExposeHeaders:
          - etag
          - x-ratelimit-remaining
        MaxAge: 3600
        AllowCredentials: false

What this does:

  • AllowOrigins: which frontend origins can read responses
  • AllowMethods: methods allowed for cross-origin access
  • AllowHeaders: request headers the browser may send
  • ExposeHeaders: response headers JS can read
  • MaxAge: how long preflight can be cached
  • AllowCredentials: whether cookies/auth credentials are allowed cross-origin

If your frontend sends Authorization, include it in AllowHeaders. If it sends JSON with Content-Type: application/json, include content-type.

SAM example

If you use AWS SAM, the config is similar:

Resources:
  MyApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      StageName: prod
      CorsConfiguration:
        AllowOrigins:
          - https://app.example.com
        AllowMethods:
          - GET
          - POST
          - OPTIONS
        AllowHeaders:
          - content-type
          - authorization
        ExposeHeaders:
          - etag
          - x-request-id
        MaxAge: 600
        AllowCredentials: false

This is enough for a lot of APIs.

Lambda backend example

With HTTP API CORS enabled, your Lambda can stay focused on application logic.

Node.js example:

export const handler = async (event) => {
  return {
    statusCode: 200,
    headers: {
      "content-type": "application/json",
      "etag": '"v1-users-123"',
      "x-request-id": event.requestContext.requestId
    },
    body: JSON.stringify({
      users: [{ id: 1, name: "Ava" }]
    })
  };
};

Notice I’m not setting Access-Control-Allow-Origin here. API Gateway should handle that.

If you add CORS headers in Lambda while API Gateway also injects them, debugging gets annoying fast.

When preflight happens

A browser sends preflight when the request is not “simple.” Common triggers:

  • POST with Content-Type: application/json
  • Any Authorization header
  • Methods like PUT, PATCH, DELETE
  • Custom headers like X-Client-Version

Frontend example that triggers preflight:

const res = await fetch("https://api.example.com/users", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer token123"
  },
  body: JSON.stringify({ name: "Ava" })
});

const data = await res.json();
console.log(data);

For this to work, your HTTP API CORS config must allow:

  • origin https://app.example.com
  • method POST
  • headers content-type and authorization

If one is missing, the browser blocks the call even if Lambda returned 200 OK.

Credentials change the rules

If you need cookies or authenticated browser credentials, CORS gets stricter.

You cannot use:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Browsers reject that combination.

So if you need credentials, configure a specific origin:

CorsConfiguration:
  AllowOrigins:
    - https://app.example.com
  AllowMethods:
    - GET
    - POST
    - OPTIONS
  AllowHeaders:
    - content-type
    - authorization
  AllowCredentials: true

And your frontend must explicitly send credentials:

const res = await fetch("https://api.example.com/session", {
  method: "GET",
  credentials: "include"
});

This is one of the most common mistakes I see: backend says credentials are allowed, frontend forgets credentials: "include", or the API uses * and expects cookies to work.

Dynamic origins: when the built-in config is not enough

Sometimes you need to allow several customer-specific origins pulled from config or a database. API Gateway’s static CORS config won’t help much there.

In that case, disable the built-in CORS config and handle CORS in Lambda yourself.

Example:

const allowedOrigins = new Set([
  "https://app.example.com",
  "https://admin.example.com"
]);

function corsHeaders(origin) {
  if (!origin || !allowedOrigins.has(origin)) {
    return {};
  }

  return {
    "Access-Control-Allow-Origin": origin,
    "Access-Control-Allow-Credentials": "true",
    "Vary": "Origin",
    "Access-Control-Expose-Headers": "ETag, X-Request-Id"
  };
}

export const handler = async (event) => {
  const origin = event.headers.origin || event.headers.Origin;

  if (event.requestContext.http.method === "OPTIONS") {
    return {
      statusCode: 204,
      headers: {
        ...corsHeaders(origin),
        "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
        "Access-Control-Allow-Headers": "content-type,authorization",
        "Access-Control-Max-Age": "3600"
      }
    };
  }

  return {
    statusCode: 200,
    headers: {
      "Content-Type": "application/json",
      "ETag": '"users-v1"',
      "X-Request-Id": event.requestContext.requestId,
      ...corsHeaders(origin)
    },
    body: JSON.stringify({ ok: true })
  };
};

Two details matter here:

  • Vary: Origin helps caches avoid serving one origin’s CORS result to another.
  • You must handle OPTIONS yourself if API Gateway isn’t doing it.

Exposing response headers properly

Browsers let frontend JS read only a small set of response headers by default.

If you want this to work:

const res = await fetch("https://api.example.com/users");
console.log(res.headers.get("etag"));
console.log(res.headers.get("x-request-id"));

you need:

Access-Control-Expose-Headers: ETag, X-Request-Id

GitHub does this heavily for useful API metadata, exposing headers like ETag, Link, and rate limit values. That’s a good pattern for developer-facing APIs.

For HTTP API built-in CORS, set:

ExposeHeaders:
  - ETag
  - X-Request-Id
  - X-RateLimit-Remaining

Common mistakes

1. Forgetting Authorization in allowed headers

If your SPA sends bearer tokens:

headers: {
  Authorization: `Bearer ${token}`
}

then authorization must be in AllowHeaders.

2. Using * with credentials

Doesn’t work. Use explicit origins.

3. Handling CORS in both API Gateway and Lambda

Pick one owner. Mixed setups are fragile.

4. Not testing preflight directly

Use curl to simulate preflight:

curl -i -X OPTIONS https://api.example.com/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type,authorization"

You want to see headers like:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Headers: content-type,authorization

5. Ignoring error responses

CORS has to work on error responses too. If your API returns 401 or 500 without CORS headers, the browser often reports it as a CORS failure instead of exposing the real error.

That makes debugging miserable.

A practical setup I’d recommend

For most teams using API Gateway HTTP APIs:

  • Configure CORS at the HTTP API level
  • Use explicit origins, not *, unless the API is truly public
  • Add authorization and content-type to allowed headers
  • Expose headers your frontend actually reads, like ETag or request IDs
  • Use credentials only when you really need cookie-based auth
  • Keep Lambda free of CORS logic unless you need dynamic origin checks

That gives you the simplest setup with the fewest moving parts.

For the official AWS behavior and config details, check the AWS docs for API Gateway HTTP API CORS: https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-cors.html

If you’re tightening broader browser security beyond CORS, headers like CSP matter too: https://csp-guide.com

CORS on API Gateway HTTP APIs is one of those things that’s easy once the ownership is clear. Let API Gateway do the boring part, and only drop to manual handling when your origin rules are genuinely dynamic.