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:
Access-Control-Allow-Origindecides who can read the response.Access-Control-Expose-Headersdecides 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 responsesAllowMethods: methods allowed for cross-origin accessAllowHeaders: request headers the browser may sendExposeHeaders: response headers JS can readMaxAge: how long preflight can be cachedAllowCredentials: 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:
POSTwithContent-Type: application/json- Any
Authorizationheader - 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-typeandauthorization
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: Originhelps caches avoid serving one origin’s CORS result to another.- You must handle
OPTIONSyourself 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
authorizationandcontent-typeto allowed headers - Expose headers your frontend actually reads, like
ETagor 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.