CORS on API Gateway
Why wildcard origins break with credentials, how preflight works, and configuring explicit CORS on HTTP APIs with CDK.
- DATE:
- APR.09.2026
- READ:
- 8 MIN
How preflight works
When a browser makes a cross-origin request that uses custom headers, sends credentials, or uses a method other than GET/HEAD/POST-with-simple-content-types, it does not send the real request immediately. Instead, it sends an OPTIONS request first — the preflight.
The preflight asks the server: “Will you accept a request from this origin, with these headers, using this method?” The server must respond with three headers:
Access-Control-Allow-Origin— the permitted originAccess-Control-Allow-Methods— the permitted HTTP methodsAccess-Control-Allow-Headers— the permitted request headers
If any of these are missing or don’t match what the browser needs, the browser blocks the real request silently. The actual request never fires. No network tab entry for it, no error response to inspect — just a CORS error in the console. The server never sees the request your frontend tried to make.
This is the part that makes CORS debugging frustrating. The failure happens between the preflight response and the real request, entirely inside the browser. Server-side logs show a successful OPTIONS response and nothing else.
The wildcard + credentials trap
A common pattern in frontend code is sending cookies or authorization headers with every request:
fetch("https://api.acmecorp.com/v1/users", {
credentials: "include",
headers: { "Authorization": `Bearer ${token}` },
});When credentials: 'include' is set, the browser enforces a stricter rule: Access-Control-Allow-Origin must be the exact requesting origin. A wildcard * is not accepted. The spec explicitly forbids the combination of Access-Control-Allow-Credentials: true with Access-Control-Allow-Origin: *. Browsers reject it silently — no special error message, just the same CORS failure.
This means the server must dynamically echo back the specific Origin from the request. On API Gateway HTTP APIs, the cors_preflight config handles this automatically when you provide an explicit list of allowed origins:
cors_preflight=apigwv2.CorsPreflightOptions(
allow_origins=[
"https://app.acmecorp.com",
"https://qa.acmecorp.com",
"https://qa2.acmecorp.com",
"https://demo.acmecorp.com",
"http://localhost:4200",
],
allow_methods=[
apigwv2.CorsHttpMethod.GET,
apigwv2.CorsHttpMethod.POST,
apigwv2.CorsHttpMethod.PUT,
apigwv2.CorsHttpMethod.DELETE,
apigwv2.CorsHttpMethod.OPTIONS,
apigwv2.CorsHttpMethod.PATCH,
],
allow_headers=[
"content-type", "authorization",
"x-csrf-token", "x-request-id",
"x-client-platform", "accept",
"cache-control", "x-requested-with",
],
allow_credentials=True,
max_age=Duration.days(10),
)API Gateway checks the incoming Origin against your list and echoes back the matching origin in the response. If the origin doesn’t match, the CORS headers are omitted entirely, and the browser blocks the request.
REST API vs HTTP API CORS
API Gateway has two flavors — REST API (v1) and HTTP API (v2). The CORS experience between them is dramatically different.
+-----------------+--------------------+--------------------+ | Feature | REST API (v1) | HTTP API (v2) | +-----------------+--------------------+--------------------+ | Setup | Manual OPTIONS | Built-in | | | method + MOCK | cors_preflight | | | integration per | config | | | resource | | +-----------------+--------------------+--------------------+ | Preflight auth | Must set OPTIONS | Auto-skips auth on | | | authorizer to NONE | OPTIONS | | | manually | | +-----------------+--------------------+--------------------+ | Error responses | Must configure | Automatic | | | Gateway Responses | | | | for CORS on | | | | 4xx/5xx | | +-----------------+--------------------+--------------------+ | Complexity | High (many moving | Low (one config | | | parts) | block) | +-----------------+--------------------+--------------------+
On REST APIs, every resource path that needs CORS requires its own OPTIONS method with a MOCK integration that returns the correct headers. If you have a Lambda authorizer attached to the API, you need to explicitly exclude the OPTIONS method from authorization — otherwise the preflight itself gets a 401, and the browser never sends the real request.
The worst REST API pitfall is error responses. When your Lambda returns a 500, API Gateway generates a Gateway Response. By default, that response has no CORS headers. The browser blocks it, and your frontend gets a generic CORS error instead of the actual error payload. You have to configure DEFAULT_4XX and DEFAULT_5XX Gateway Responses with the correct CORS headers — a step that’s easy to forget and hard to debug.
HTTP API handles all of this with the single cors_preflight block shown above. OPTIONS requests bypass authorizers automatically. Error responses include CORS headers automatically. One config block, zero per-resource setup.
Common debugging patterns
Missing Vary: Origin header. When a CDN like CloudFront sits in front of your API, the CDN caches responses including CORS headers. If the first request comes from https://app.acmecorp.com and the CDN caches that response, a subsequent request from https://qa.acmecorp.com gets the cached headers with the wrong origin. The fix is ensuring the API returns Vary: Origin, which tells the CDN to cache separate responses per origin. HTTP API does this automatically when multiple origins are configured.
Cached preflight masking fixes. The max_age directive tells the browser how long to cache a preflight response. If you set it to 10 days and then fix your CORS config, browsers that cached the old preflight will still fail for up to 10 days. During debugging, set max_age to Duration.seconds(0) so every request triggers a fresh preflight.
Lambda proxy integration. When using Lambda proxy integration (which is the default on HTTP API), the Lambda function itself must return CORS headers in the response body. The cors_preflight config only handles the OPTIONS preflight response. The actual GET/POST/PUT response comes directly from your Lambda, and if it doesn’t include Access-Control-Allow-Origin in its response headers, the browser blocks it. This catches everyone at least once.
Missing CORS headers on non-2xx responses. Your Lambda’s error handling path needs to include CORS headers too. If your Lambda returns a 400 validation error without CORS headers, the frontend sees a CORS error, not the validation message. Every response path in your Lambda — success, client error, server error — must include the CORS headers.
CORS vs CSRF
CORS and CSRF are often conflated, but they solve different problems. CORS is a browser mechanism that restricts which origins can read responses from your API. It prevents unauthorized cross-origin reads. It does not prevent writes.
A malicious page can still send a POST request to your API — the browser will send it, including cookies. CORS only prevents the malicious page from reading the response. If the POST creates an order, transfers money, or deletes data, the damage is done regardless of whether the attacker can read the response.
CSRF tokens protect against unauthorized state-changing requests. The server issues a token that the legitimate frontend includes in requests. A malicious page on a different origin cannot read the token (CORS prevents that), so it cannot forge a valid request.
You need both. CORS prevents cross-origin reads. CSRF tokens prevent cross-origin writes. Neither is a substitute for the other.
CORS tells the browser who can read. CSRF tokens tell the server who can write. Configure both — skipping either leaves a gap that the other does not cover.