If you’ve ever shipped a frontend against AWS API Gateway, you’ve probably had that moment: the API works fine in Postman, maybe even with curl, but the browser throws a CORS error and gives you almost nothing useful.
That’s the thing about CORS with API Gateway: the browser enforces it, API Gateway partially helps, and your backend can still ruin everything.
I’ve seen teams lose hours because they enabled “CORS” in the console and assumed they were done. Usually they weren’t.
What CORS actually needs from API Gateway
For a browser request to succeed cross-origin, you need the response to include the right headers. The bare minimum is usually:
Access-Control-Allow-Origin: https://app.example.com
If the browser sends a preflight OPTIONS request, your API also needs to answer with things like:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization
And if your frontend needs to read non-simple response headers, you also need:
Access-Control-Expose-Headers: ETag, Link
A real example: GitHub’s API returns:
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 access-control-expose-headers list matters. Without it, your frontend JavaScript can’t read most of those headers even though they exist in the network response.
If you want to sanity-check your own API responses, HeaderTest is handy for seeing exactly which headers are returned and whether they line up with what the browser expects.
API Gateway has two worlds: REST API and HTTP API
AWS made this confusing by having two API Gateway products:
- REST API: older, more configurable, more annoying
- HTTP API: newer, cheaper, simpler, usually the better choice
CORS setup is different between them.
CORS in API Gateway HTTP API
HTTP APIs have built-in CORS configuration that is actually decent. If you’re starting fresh, use this unless you need REST API features.
Example with AWS SAM
Resources:
MyHttpApi:
Type: AWS::ApiGatewayV2::Api
Properties:
Name: my-http-api
ProtocolType: HTTP
CorsConfiguration:
AllowOrigins:
- https://app.example.com
AllowMethods:
- GET
- POST
- OPTIONS
AllowHeaders:
- Content-Type
- Authorization
ExposeHeaders:
- ETag
- Link
- X-Request-Id
AllowCredentials: false
MaxAge: 600
That tells API Gateway to handle preflight responses for you.
Lambda handler behind HTTP API
Your Lambda still needs to return CORS headers on the actual response. People miss this all the time.
export const handler = async (event) => {
return {
statusCode: 200,
headers: {
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Expose-Headers": "ETag, Link, X-Request-Id",
"Content-Type": "application/json",
"ETag": '"abc123"',
"X-Request-Id": "req-789"
},
body: JSON.stringify({
ok: true
})
};
};
If you don’t include Access-Control-Allow-Origin on the actual GET or POST response, the browser still blocks it even if preflight passed.
That’s one of the most common API Gateway CORS mistakes.
CORS in API Gateway REST API
REST API is where things get more painful. The “Enable CORS” button in the console is not magic. It tends to create an OPTIONS method and patch some method responses, but it won’t always fix your backend integration responses, error responses, or Lambda output.
You need to think in three layers:
- Preflight
OPTIONS - Actual success responses
- Actual error responses
Miss any of those and the browser complains.
Example: Lambda proxy integration
If you use Lambda proxy integration, your Lambda is responsible for returning the CORS headers.
exports.handler = async (event) => {
const origin = event.headers.origin || event.headers.Origin;
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com"
]);
const allowOrigin = allowedOrigins.has(origin)
? origin
: "https://app.example.com";
return {
statusCode: 200,
headers: {
"Access-Control-Allow-Origin": allowOrigin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Expose-Headers": "ETag, Link",
"Content-Type": "application/json",
"ETag": '"v1-resource"'
},
body: JSON.stringify({ message: "Hello from Lambda" })
};
};
Preflight OPTIONS for REST API
You can create a mock integration for OPTIONS that returns the required headers.
Example response headers:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization,X-Requested-With
Access-Control-Max-Age: 600
With Terraform, that looks roughly like this:
resource "aws_api_gateway_method" "options" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.items.id
http_method = "OPTIONS"
authorization = "NONE"
}
resource "aws_api_gateway_integration" "options" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.items.id
http_method = aws_api_gateway_method.options.http_method
type = "MOCK"
request_templates = {
"application/json" = "{\"statusCode\": 200}"
}
}
resource "aws_api_gateway_method_response" "options_200" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.items.id
http_method = aws_api_gateway_method.options.http_method
status_code = "200"
response_parameters = {
"method.response.header.Access-Control-Allow-Origin" = true
"method.response.header.Access-Control-Allow-Methods" = true
"method.response.header.Access-Control-Allow-Headers" = true
"method.response.header.Access-Control-Max-Age" = true
}
}
resource "aws_api_gateway_integration_response" "options_200" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.items.id
http_method = aws_api_gateway_method.options.http_method
status_code = aws_api_gateway_method_response.options_200.status_code
response_parameters = {
"method.response.header.Access-Control-Allow-Origin" = "'https://app.example.com'"
"method.response.header.Access-Control-Allow-Methods" = "'GET,POST,PUT,DELETE,OPTIONS'"
"method.response.header.Access-Control-Allow-Headers" = "'Content-Type,Authorization,X-Requested-With'"
"method.response.header.Access-Control-Max-Age" = "'600'"
}
}
It’s verbose, but that’s REST API for you.
Don’t use * blindly
A lot of examples use:
Access-Control-Allow-Origin: *
That’s fine for public, unauthenticated APIs. GitHub does it on api.github.com, which makes sense for broad public access.
But if you need cookies or Authorization with credentials mode, * is not valid in the way people expect. Browsers reject credentialed CORS responses unless the origin is explicit.
If you need credentials:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
And don’t forget to vary by origin at the caching layer:
Vary: Origin
If CloudFront sits in front of API Gateway and you dynamically reflect allowed origins, Vary: Origin is not optional. Otherwise one origin can get another origin’s cached CORS response, which gets weird fast.
Handling multiple allowed origins safely
A pattern I like in Lambda:
const allowedOrigins = [
"https://app.example.com",
"https://staging.example.com"
];
function getCorsHeaders(requestOrigin) {
if (allowedOrigins.includes(requestOrigin)) {
return {
"Access-Control-Allow-Origin": requestOrigin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Expose-Headers": "ETag, Link",
"Vary": "Origin"
};
}
return {
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Expose-Headers": "ETag, Link",
"Vary": "Origin"
};
}
Then use it in every response path, including errors.
Error responses are where CORS usually breaks
Your happy-path 200 OK might have perfect CORS headers. Then Lambda throws, API Gateway returns a 502, and suddenly the browser shows a generic CORS failure instead of the real error.
For REST API, configure Gateway Responses for errors like DEFAULT_4XX and DEFAULT_5XX.
Example idea:
{
"responseParameters": {
"gatewayresponse.header.Access-Control-Allow-Origin": "'https://app.example.com'",
"gatewayresponse.header.Access-Control-Allow-Headers": "'Content-Type,Authorization'",
"gatewayresponse.header.Access-Control-Allow-Methods": "'GET,POST,OPTIONS'"
}
}
If you skip this, debugging gets miserable because every backend failure looks like “CORS blocked.”
Common frontend request that triggers preflight
This fetch call triggers preflight because of the Authorization header:
const res = await fetch("https://api.example.com/items", {
method: "GET",
headers: {
"Authorization": "Bearer token123"
}
});
const etag = res.headers.get("ETag");
const data = await res.json();
For that to work:
OPTIONSmust allowAuthorization- actual response must include
Access-Control-Allow-Origin - actual response must expose
ETagif you wantres.headers.get("ETag")to work
Without Access-Control-Expose-Headers: ETag, etag comes back null in the browser even if DevTools shows the header.
API Gateway console “Enable CORS” is a starting point, not a fix
My honest opinion: treat the console CORS toggle as scaffolding, not the final config.
You still need to verify:
- preflight
OPTIONSworks - success responses include CORS headers
- 4xx and 5xx responses include CORS headers
- exposed headers are listed if frontend code reads them
- credentialed requests use explicit origins, not
*
CORS is not auth
CORS does not protect your API from server-to-server access, curl, bots, or malicious clients. It only tells browsers which cross-origin frontend code may read responses.
If you need real protection, use auth, authorizers, WAF rules, rate limiting, and proper security headers. If you’re reviewing the rest of your response headers too, csp-guide.com is useful for the CSP side of things.
A practical checklist
When I’m debugging API Gateway CORS, I check these in order:
- Does the browser send preflight?
- Does
OPTIONSreturn 200 with correctAllow-*headers? - Does the actual response include
Access-Control-Allow-Origin? - If using cookies or credentials, is the origin explicit and
Allow-Credentials: trueset? - If reading headers in JS, are they listed in
Access-Control-Expose-Headers? - Do error responses also include CORS headers?
- If caching is involved, is
Vary: Originpresent?
That checklist catches most API Gateway CORS bugs.
AWS gives you just enough tooling to think CORS is solved. It usually isn’t until you test the full browser flow end to end. Use curl if you want, but always verify in a real browser too, because CORS is one of those problems where the browser is the final judge.