CORS on API Gateway looks easy until the browser starts throwing vague errors and Terraform happily deploys a broken setup.
I’ve hit this enough times that I now treat CORS as a first-class part of the API contract, not a checkbox. If you manage AWS API Gateway with Terraform, the main thing to remember is this: CORS is enforced by browsers, but you implement it in API Gateway and your backend responses.
This guide is the practical version. No fluff, just what to copy, what to change, and where people usually mess it up.
What CORS actually needs
For a browser request to succeed across origins, your API needs to return the right headers.
The core response header is:
Access-Control-Allow-Origin: https://app.example.com
Sometimes people use:
Access-Control-Allow-Origin: *
That works for public APIs, but not with credentials like cookies or Authorization-bearing browser requests where you want credentials: 'include'.
A preflight OPTIONS response usually needs:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Max-Age: 86400
If your frontend needs to read non-simple response headers, add:
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After
A real-world example: api.github.com exposes a bunch of useful headers:
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
access-control-allow-origin: *
That’s a good reminder that CORS is not only about allowing requests. It also controls what browser JavaScript can read from the response.
The three API Gateway cases that matter
With Terraform on AWS, you’ll usually be in one of these buckets:
- API Gateway REST API v1
- API Gateway HTTP API v2
- Lambda proxy integration where Lambda returns headers directly
The setup is different enough that mixing examples is a fast path to pain.
HTTP API v2: easiest CORS in Terraform
If you’re using API Gateway HTTP API, use the built-in CORS block unless you have a weird edge case.
resource "aws_apigatewayv2_api" "this" {
name = "example-http-api"
protocol_type = "HTTP"
cors_configuration {
allow_origins = ["https://app.example.com"]
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allow_headers = ["content-type", "authorization"]
expose_headers = ["etag", "link", "location", "retry-after"]
max_age = 86400
}
}
That’s the cleanest option.
If you need credentials:
resource "aws_apigatewayv2_api" "this" {
name = "example-http-api"
protocol_type = "HTTP"
cors_configuration {
allow_origins = ["https://app.example.com"]
allow_methods = ["GET", "POST", "OPTIONS"]
allow_headers = ["content-type", "authorization"]
allow_credentials = true
max_age = 3600
}
}
Rule you should not ignore
If allow_credentials = true, do not use:
allow_origins = ["*"]
Browsers reject that combination.
Good frontend test
fetch("https://api.example.com/profile", {
method: "GET",
credentials: "include",
headers: {
"Authorization": "Bearer token"
}
})
.then(r => r.json())
.then(console.log)
If this fails in the browser but works in curl, your CORS config is wrong, not your auth logic.
REST API v1: more verbose, more annoying
REST API CORS in Terraform is more manual. You need to wire up OPTIONS, response headers, and often your actual method responses too.
Here’s a copy-paste example for a single /items resource.
Resource and OPTIONS method
resource "aws_api_gateway_rest_api" "this" {
name = "example-rest-api"
}
resource "aws_api_gateway_resource" "items" {
rest_api_id = aws_api_gateway_rest_api.this.id
parent_id = aws_api_gateway_rest_api.this.root_resource_id
path_part = "items"
}
resource "aws_api_gateway_method" "items_options" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.items.id
http_method = "OPTIONS"
authorization = "NONE"
}
Mock integration for preflight
resource "aws_api_gateway_integration" "items_options" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.items.id
http_method = aws_api_gateway_method.items_options.http_method
type = "MOCK"
request_templates = {
"application/json" = "{\"statusCode\": 200}"
}
}
Method response headers
resource "aws_api_gateway_method_response" "items_options_200" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.items.id
http_method = aws_api_gateway_method.items_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
}
}
Integration response with actual CORS values
resource "aws_api_gateway_integration_response" "items_options_200" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.items.id
http_method = aws_api_gateway_method.items_options.http_method
status_code = aws_api_gateway_method_response.items_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'"
"method.response.header.Access-Control-Max-Age" = "'86400'"
}
}
That handles preflight.
You also need CORS on the real response
This is the part people skip. The browser checks the actual GET or POST response too.
For a GET method:
resource "aws_api_gateway_method" "items_get" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.items.id
http_method = "GET"
authorization = "NONE"
}
resource "aws_api_gateway_method_response" "items_get_200" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.items.id
http_method = aws_api_gateway_method.items_get.http_method
status_code = "200"
response_parameters = {
"method.response.header.Access-Control-Allow-Origin" = true
"method.response.header.Access-Control-Expose-Headers" = true
}
}
If you use a non-proxy integration, set them in the integration response:
resource "aws_api_gateway_integration_response" "items_get_200" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.items.id
http_method = aws_api_gateway_method.items_get.http_method
status_code = aws_api_gateway_method_response.items_get_200.status_code
response_parameters = {
"method.response.header.Access-Control-Allow-Origin" = "'https://app.example.com'"
"method.response.header.Access-Control-Expose-Headers" = "'ETag,Link,Location,Retry-After'"
}
}
If you use Lambda proxy integration, return the headers from Lambda instead.
Lambda proxy integration: often the least confusing
With Lambda proxy, I usually prefer returning CORS headers directly from the function. It keeps behavior close to the application code.
Node.js example
export const handler = async () => {
return {
statusCode: 200,
headers: {
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Expose-Headers": "ETag,Link,Location,Retry-After",
"Content-Type": "application/json"
},
body: JSON.stringify({ ok: true })
};
};
For preflight:
export const handler = async (event) => {
if (event.requestContext.http?.method === "OPTIONS" || event.httpMethod === "OPTIONS") {
return {
statusCode: 204,
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",
"Access-Control-Max-Age": "86400"
},
body: ""
};
}
return {
statusCode: 200,
headers: {
"Access-Control-Allow-Origin": "https://app.example.com"
},
body: JSON.stringify({ ok: true })
};
};
Dynamic origin allowlist
This is useful when you have staging and production frontends.
const allowedOrigins = new Set([
"https://app.example.com",
"https://staging.example.com"
]);
export const handler = async (event) => {
const origin = event.headers.origin || event.headers.Origin;
const allowOrigin = allowedOrigins.has(origin) ? origin : "https://app.example.com";
return {
statusCode: 200,
headers: {
"Access-Control-Allow-Origin": allowOrigin,
"Vary": "Origin",
"Content-Type": "application/json"
},
body: JSON.stringify({ ok: true })
};
};
If you vary by request origin, send:
Vary: Origin
Otherwise caches can serve the wrong CORS response to the wrong site.
Common mistakes
1. Preflight works, actual request fails
You added CORS to OPTIONS only. The real GET/POST response also needs Access-Control-Allow-Origin.
2. Using * with credentials
This fails in browsers:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Use the exact origin instead.
3. Forgetting allowed headers
If the browser sends:
Access-Control-Request-Headers: authorization,content-type
your preflight response must allow them.
4. Not exposing useful response headers
Without Access-Control-Expose-Headers, frontend code cannot read headers like ETag, Link, or Retry-After even though they exist.
A practical exposed header list:
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After
If your API has rate limiting, expose those too.
5. Terraform deployed, API still stale
For REST API deployments, remember that config changes often need a new deployment resource and stage update. If your Terraform is correct but behavior didn’t change, this is my first suspect.
Fast curl checks
Preflight test:
curl -i -X OPTIONS "https://api.example.com/items" \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,authorization"
Actual request test:
curl -i "https://api.example.com/items" \
-H "Origin: https://app.example.com"
You want to see the right Access-Control-* headers in both cases.
Sensible defaults
For a private SPA talking to your API:
Allow-Origin: exact frontend origin
Allow-Methods: only what you use
Allow-Headers: content-type, authorization
Allow-Credentials: true if using cookies/session auth
Expose-Headers: etag, link, location, retry-after
Max-Age: 3600 or 86400
For a public read-only API:
Allow-Origin: *
Allow-Methods: GET, OPTIONS
Allow-Headers: content-type
Expose-Headers: etag, link, retry-after
Keep CORS narrow. A lot of teams treat it like a convenience setting and then wonder why they’ve exposed more than they intended.
For AWS specifics, the official docs are the ones worth checking when behavior gets weird: https://docs.aws.amazon.com/apigateway/ https://docs.aws.amazon.com/terraform/
If you’re tightening broader HTTP response security beyond CORS, things like CSP belong in a separate conversation. For that, I’d use a dedicated reference such as https://csp-guide.com.