Configuring CORS Preflight in AWS API Gateway

Failure symptom:

Access to XMLHttpRequest at 'https://abc123.execute-api.us-east-1.amazonaws.com/prod/orders'
from origin 'https://app.example.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

The preflight OPTIONS either returns 403 Missing Authentication Token (no OPTIONS method exists on the resource) or reaches your Lambda function, which never emits CORS headers on the preflight.

Root Cause

API Gateway does not add CORS handling by default, and the mechanism differs between the two product types. In a REST API, every method — including OPTIONS — must be defined explicitly; if the resource has no OPTIONS method, the gateway rejects the preflight with 403 before any integration runs. In an HTTP API, CORS is a first-class configuration block, and forgetting to declare it means no Access-Control-Allow-Origin is ever produced. Either way, the browser’s preflight (WHATWG Fetch Standard §4.8) demands Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers on the OPTIONS response, and API Gateway supplies none of them until you configure it.

This page is part of Proxy Bypass Strategies for CORS Preflight, which covers terminating preflight at reverse proxies, CDNs, and API gateways.

Prerequisite State

Step-by-Step Fix

Step 1 — Decide which path applies

The diagram below shows the two mechanisms. Pick the branch matching your API type; do not mix a mock OPTIONS integration with HTTP API CorsConfiguration.

API Gateway CORS: REST mock integration vs HTTP API CorsConfiguration A preflight OPTIONS request enters API Gateway. On the REST API path it is answered by a mock integration that maps Access-Control response headers and never invokes Lambda. On the HTTP API path the built-in CorsConfiguration block answers OPTIONS automatically. Browser OPTIONS API Gateway API type? REST API Mock OPTIONS integration maps Access-Control headers HTTP API CorsConfiguration block gateway answers OPTIONS Never enable both on the same route

Step 2 — REST API: add an OPTIONS mock integration

A mock integration lets API Gateway answer OPTIONS entirely within the gateway — no Lambda or backend invocation. Define the OPTIONS method, map the response headers in the method response, and set their static values in the integration response.

# OpenAPI 3 with x-amazon-apigateway-integration (REST API)
paths:
  /orders:
    options:
      summary: CORS preflight
      responses:
        "204":
          description: Preflight response
          headers:
            Access-Control-Allow-Origin:
              schema: {type: string}
            Access-Control-Allow-Methods:
              schema: {type: string}
            Access-Control-Allow-Headers:
              schema: {type: string}
            Access-Control-Max-Age:
              schema: {type: string}
      x-amazon-apigateway-integration:
        type: mock
        requestTemplates:
          application/json: '{"statusCode": 204}'
        responses:
          default:
            statusCode: "204"
            responseParameters:
              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: "'Authorization,Content-Type'"
              method.response.header.Access-Control-Max-Age:       "'600'"

The single-quotes-inside-double-quotes syntax ("'value'") is required: API Gateway treats the mapping value as a VTL expression, so a literal string must be quoted twice. Because this is a mock, your Lambda is never invoked for OPTIONS.

Step 3 — HTTP API: declare a CorsConfiguration block

HTTP APIs generate the OPTIONS handling for you when you declare a CorsConfiguration. No mock method is needed and none should be added.

# CloudFormation / SAM — HTTP API (AWS::ApiGatewayV2::Api)
Resources:
  HttpApi:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: orders-http-api
      ProtocolType: HTTP
      CorsConfiguration:
        AllowOrigins:
          - https://app.example.com
          - https://admin.example.com
        AllowMethods:
          - GET
          - POST
          - PUT
          - DELETE
          - OPTIONS
        AllowHeaders:
          - Authorization
          - Content-Type
        AllowCredentials: true
        MaxAge: 600

With AllowCredentials: true, AllowOrigins must be an explicit list — the * wildcard is rejected in the credentialed combination, matching the Fetch spec prohibition.

Step 4 — Prevent double CORS with Lambda proxy

If the actual method uses Lambda proxy (AWS_PROXY), decide which layer owns CORS. When HTTP API CorsConfiguration is enabled, do not also set Access-Control-Allow-Origin in the function response, or the browser receives two values and rejects the response.

// Lambda proxy handler for the ACTUAL request (e.g. POST /orders).
// CorsConfiguration owns the header, so the function must NOT set it.
export const handler = async (event) => {
  const order = await createOrder(JSON.parse(event.body));
  return {
    statusCode: 201,
    // No Access-Control-Allow-Origin here when CorsConfiguration is on
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(order),
  };
};

If instead the function owns CORS (no CorsConfiguration), the function must set Access-Control-Allow-Origin on every response, and for a REST API you still need the Step 2 mock for OPTIONS.

Verification

curl the preflight — for a REST API expect 204; for an HTTP API expect 204 as well:

curl -sI -X OPTIONS \
  'https://abc123.execute-api.us-east-1.amazonaws.com/prod/orders' \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: Authorization, Content-Type'

Expected headers: Access-Control-Allow-Origin: https://app.example.com, Access-Control-Allow-Methods, and Access-Control-Allow-Headers.

Confirm no duplicate header — a single value only:

curl -si -X OPTIONS \
  'https://abc123.execute-api.us-east-1.amazonaws.com/prod/orders' \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' | grep -ci 'access-control-allow-origin:'

Expected: 1. A value of 2 means both the gateway and Lambda are emitting the header — remove one. In Chrome DevTools, filter the Network panel by Preflight and confirm the OPTIONS row is 204 with a single Access-Control-Allow-Origin.

Security Boundary Note

API Gateway’s built-in CORS matches origins against your fixed AllowOrigins list; it does not reflect an arbitrary Origin. Do not work around this by setting AllowOrigins: ["*"] together with AllowCredentials: true — that combination is invalid and browsers reject the credentialed response, and even without credentials a wildcard grants every origin access. If you need per-request origin reflection, validate the Origin against an allowlist in a Lambda authorizer or the integration before echoing it, exactly as in Dynamic Origin Validation Patterns. Wildcard-with-credentials pitfalls are covered in Wildcard Risks & Mitigation.

Common Mistakes

Issue Technical impact Mitigation
No OPTIONS method on a REST API resource Preflight returns 403 Missing Authentication Token; browser reports a CORS failure Add an OPTIONS mock integration per resource (Step 2)
Routing REST OPTIONS to Lambda proxy Function is invoked for preflight but returns no CORS headers Use a mock integration for OPTIONS; keep Lambda for real methods only
Both CorsConfiguration and Lambda set Access-Control-Allow-Origin Duplicate header; browser treats the response as malformed Let exactly one layer own CORS; remove the header from the other
AllowOrigins: ["*"] with AllowCredentials: true Invalid combination; browser rejects the credentialed response List exact origins; never combine * with credentials

FAQ

What is the difference between REST API and HTTP API CORS in API Gateway?

In a REST API you must add the OPTIONS method yourself, usually as a mock integration, and map each Access-Control response header in the method response and integration response. In an HTTP API you declare a CorsConfiguration block and API Gateway synthesises the OPTIONS handling for you. HTTP API CORS is simpler but less granular; REST API gives per-method control at the cost of more configuration.

Why does my Lambda proxy integration return no CORS headers on the preflight?

With Lambda proxy integration (AWS_PROXY), API Gateway does not add CORS headers automatically for the actual request; your function must return them in the response object. For the OPTIONS preflight in a REST API, do not route it to Lambda at all — use a mock integration so the gateway answers it without invoking the function.

Why am I seeing duplicate Access-Control-Allow-Origin headers?

Double CORS happens when both API Gateway’s CorsConfiguration (or mock integration) and your Lambda function set Access-Control-Allow-Origin. Pick one layer. If HTTP API CorsConfiguration is enabled, stop setting the header in the function; if the function owns CORS, leave CorsConfiguration off.