CORS on AWS API Gateway

Amazon API Gateway provides robust CORS support with significant differences between its two API types. This guide covers modern, secure CORS implementations using Infrastructure as Code and best practices.

AWS API Gateway has two types:

  • HTTP API - Simpler, cheaper (~70% less), better CORS support (recommended for new projects)
  • REST API - More features (API keys, usage plans, request validation) but complex CORS configuration

Choose HTTP API unless you need REST API-specific features.

⚠️ Security Warning: Using Access-Control-Allow-Origin: * allows any website to access your resources. Always specify exact origins in production.

HTTP API (Recommended)

HTTP API provides simpler CORS configuration with automatic OPTIONS handling.

AWS Console Configuration

  1. Open AWS API Gateway console
  2. Create or select HTTP API
  3. Go to CORS settings
  4. Configure:
    • Access-Control-Allow-Origin: https://example.com
    • Access-Control-Allow-Methods: GET,POST,PUT,DELETE
    • Access-Control-Allow-Headers: Content-Type,Authorization
    • Access-Control-Max-Age: 86400
    • Access-Control-Allow-Credentials: Yes (if needed)
  5. Save

That's it! HTTP API handles OPTIONS preflight requests automatically.

AWS SAM / CloudFormation (Recommended)

Infrastructure as Code is the recommended approach for production deployments:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Resources:
  # HTTP API (Simple, Recommended)
  MyHttpApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      CorsConfiguration:
        AllowOrigins:
          - https://example.com
          - https://app.example.com
        AllowMethods:
          - GET
          - POST
          - PUT
          - DELETE
          - OPTIONS
        AllowHeaders:
          - Content-Type
          - Authorization
        MaxAge: 86400
        AllowCredentials: true

  # Lambda Function
  MyFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.11
      Handler: index.handler
      InlineCode: |
        import json
        def handler(event, context):
            return {
                'statusCode': 200,
                'body': json.dumps({'message': 'Success'})
            }
      Events:
        ApiEvent:
          Type: HttpApi
          Properties:
            ApiId: !Ref MyHttpApi
            Path: /data
            Method: get

AWS CDK (TypeScript)

import * as cdk from 'aws-cdk-lib';
import * as apigatewayv2 from '@aws-cdk/aws-apigatewayv2-alpha';
import * as lambda from 'aws-cdk-lib/aws-lambda';

export class CorsStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string) {
    super(scope, id);

    // HTTP API (Recommended - Simpler)
    const httpApi = new apigatewayv2.HttpApi(this, 'HttpApi', {
      apiName: 'my-http-api',
      corsPreflight: {
        allowOrigins: [
          'https://example.com',
          'https://app.example.com'
        ],
        allowMethods: [
          apigatewayv2.CorsHttpMethod.GET,
          apigatewayv2.CorsHttpMethod.POST,
          apigatewayv2.CorsHttpMethod.PUT,
          apigatewayv2.CorsHttpMethod.DELETE
        ],
        allowHeaders: ['Content-Type', 'Authorization'],
        maxAge: cdk.Duration.days(1),
        allowCredentials: true
      }
    });
  }
}

Terraform

resource "aws_apigatewayv2_api" "http_api" {
  name          = "my-http-api"
  protocol_type = "HTTP"

  cors_configuration {
    allow_origins = [
      "https://example.com",
      "https://app.example.com"
    ]
    allow_methods     = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
    allow_headers     = ["Content-Type", "Authorization"]
    max_age           = 86400
    allow_credentials = true
  }
}

resource "aws_apigatewayv2_integration" "lambda" {
  api_id           = aws_apigatewayv2_api.http_api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.my_function.invoke_arn
}

resource "aws_apigatewayv2_route" "get_data" {
  api_id    = aws_apigatewayv2_api.http_api.id
  route_key = "GET /data"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}

REST API

REST API requires more complex CORS configuration with manual OPTIONS method setup.

AWS SAM / CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Resources:
  MyRestApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      Cors:
        AllowOrigin: "'https://example.com'"
        AllowHeaders: "'Content-Type,Authorization'"
        AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"
        MaxAge: "'86400'"
        AllowCredentials: true
      # Add CORS to error responses
      GatewayResponses:
        DEFAULT_4XX:
          ResponseParameters:
            gatewayresponse.header.Access-Control-Allow-Origin: "'https://example.com'"
            gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type,Authorization'"
        DEFAULT_5XX:
          ResponseParameters:
            gatewayresponse.header.Access-Control-Allow-Origin: "'https://example.com'"
            gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type,Authorization'"

  MyFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.11
      Handler: index.handler
      InlineCode: |
        import json
        def handler(event, context):
            return {
                'statusCode': 200,
                'body': json.dumps({'message': 'Success'})
            }
      Events:
        ApiEvent:
          Type: Api
          Properties:
            RestApiId: !Ref MyRestApi
            Path: /data
            Method: get

AWS CDK (TypeScript)

import * as cdk from 'aws-cdk-lib';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as lambda from 'aws-cdk-lib/aws-lambda';

export class CorsStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string) {
    super(scope, id);

    // REST API (More Features)
    const restApi = new apigateway.RestApi(this, 'RestApi', {
      restApiName: 'my-rest-api',
      defaultCorsPreflightOptions: {
        allowOrigins: [
          'https://example.com',
          'https://app.example.com'
        ],
        allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
        allowHeaders: ['Content-Type', 'Authorization'],
        maxAge: cdk.Duration.days(1),
        allowCredentials: true
      }
    });

    // Lambda integration
    const handler = new lambda.Function(this, 'Handler', {
      runtime: lambda.Runtime.PYTHON_3_11,
      code: lambda.Code.fromInline(`
import json
def handler(event, context):
    return {
        'statusCode': 200,
        'body': json.dumps({'message': 'Success'})
    }
      `),
      handler: 'index.handler'
    });

    const resource = restApi.root.addResource('data');
    resource.addMethod('GET', new apigateway.LambdaIntegration(handler));
  }
}

Terraform

resource "aws_api_gateway_rest_api" "rest_api" {
  name = "my-rest-api"
}

resource "aws_api_gateway_resource" "resource" {
  rest_api_id = aws_api_gateway_rest_api.rest_api.id
  parent_id   = aws_api_gateway_rest_api.rest_api.root_resource_id
  path_part   = "data"
}

# OPTIONS method for preflight
resource "aws_api_gateway_method" "options" {
  rest_api_id   = aws_api_gateway_rest_api.rest_api.id
  resource_id   = aws_api_gateway_resource.resource.id
  http_method   = "OPTIONS"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "options" {
  rest_api_id = aws_api_gateway_rest_api.rest_api.id
  resource_id = aws_api_gateway_resource.resource.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.rest_api.id
  resource_id = aws_api_gateway_resource.resource.id
  http_method = aws_api_gateway_method.options.http_method
  status_code = "200"

  response_parameters = {
    "method.response.header.Access-Control-Allow-Headers" = true
    "method.response.header.Access-Control-Allow-Methods" = true
    "method.response.header.Access-Control-Allow-Origin"  = true
  }
}

resource "aws_api_gateway_integration_response" "options_200" {
  rest_api_id = aws_api_gateway_rest_api.rest_api.id
  resource_id = aws_api_gateway_resource.resource.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-Headers" = "'Content-Type,Authorization'"
    "method.response.header.Access-Control-Allow-Methods" = "'GET,POST,PUT,DELETE,OPTIONS'"
    "method.response.header.Access-Control-Allow-Origin"  = "'https://example.com'"
  }
}

Lambda Function CORS Handling

For advanced scenarios, handle CORS in your Lambda function for complete control:

Python Lambda

import json

def lambda_handler(event, context):
    # Get origin from request
    origin = event.get('headers', {}).get('origin', '')

    # Validate origin
    allowed_origins = [
        'https://example.com',
        'https://app.example.com'
    ]

    cors_headers = {}
    if origin in allowed_origins:
        cors_headers = {
            'Access-Control-Allow-Origin': origin,
            'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
            'Access-Control-Allow-Headers': 'Content-Type,Authorization',
            'Access-Control-Max-Age': '86400',
            'Access-Control-Allow-Credentials': 'true',
            'Vary': 'Origin'
        }

    # Handle preflight
    request_context = event.get('requestContext', {})
    http_method = request_context.get('http', {}).get('method') or request_context.get('httpMethod')

    if http_method == 'OPTIONS':
        return {
            'statusCode': 204,
            'headers': cors_headers,
            'body': ''
        }

    # Handle actual request
    return {
        'statusCode': 200,
        'headers': {
            **cors_headers,
            'Content-Type': 'application/json'
        },
        'body': json.dumps({'message': 'Success'})
    }

Node.js Lambda

exports.handler = async (event) => {
    const origin = event.headers?.origin || '';

    const allowedOrigins = [
        'https://example.com',
        'https://app.example.com'
    ];

    const corsHeaders = {};
    if (allowedOrigins.includes(origin)) {
        corsHeaders['Access-Control-Allow-Origin'] = origin;
        corsHeaders['Access-Control-Allow-Methods'] = 'GET,POST,PUT,DELETE,OPTIONS';
        corsHeaders['Access-Control-Allow-Headers'] = 'Content-Type,Authorization';
        corsHeaders['Access-Control-Max-Age'] = '86400';
        corsHeaders['Access-Control-Allow-Credentials'] = 'true';
        corsHeaders['Vary'] = 'Origin';
    }

    // Handle preflight
    const httpMethod = event.requestContext?.http?.method ||
                      event.requestContext?.httpMethod;

    if (httpMethod === 'OPTIONS') {
        return {
            statusCode: 204,
            headers: corsHeaders,
            body: ''
        };
    }

    // Handle actual request
    return {
        statusCode: 200,
        headers: {
            ...corsHeaders,
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ message: 'Success' })
    };
};

Common Pitfalls

Pitfall 1: Forgetting to Deploy

After making CORS changes in the console for REST APIs, you MUST deploy:

aws apigateway create-deployment \
  --rest-api-id YOUR_API_ID \
  --stage-name prod

Pitfall 2: Missing CORS on Error Responses

REST API only adds CORS headers to 200 responses by default. Configure gateway responses for error codes:

# SAM template
GatewayResponses:
  DEFAULT_4XX:
    ResponseParameters:
      gatewayresponse.header.Access-Control-Allow-Origin: "'https://example.com'"
      gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type'"
  DEFAULT_5XX:
    ResponseParameters:
      gatewayresponse.header.Access-Control-Allow-Origin: "'https://example.com'"
      gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type'"

Pitfall 3: Credentials with Wildcard

This combination is INVALID per CORS specification:

# INVALID - Will fail in browsers
AllowOrigin: "'*'"
AllowCredentials: true

Must use specific origin:

# VALID
AllowOrigin: "'https://example.com'"
AllowCredentials: true

HTTP API vs REST API Feature Comparison

Feature HTTP API REST API
CORS Configuration Simple, built-in Complex, manual OPTIONS
Price Cheaper (~70% less) More expensive
Latency Lower Higher
API Keys No Yes
Usage Plans No Yes
Request Validation No Yes

Monitoring and Debugging

Enable CloudWatch Logs

# For HTTP API
aws apigatewayv2 update-stage \
  --api-id YOUR_API_ID \
  --stage-name '$default' \
  --access-log-settings DestinationArn=arn:aws:logs:region:account:log-group:name

# View logs
aws logs tail /aws/apigateway/YOUR_API_ID --follow

Check API Configuration

# Get HTTP API details
aws apigatewayv2 get-api --api-id YOUR_API_ID

# Get REST API details
aws apigateway get-rest-api --rest-api-id YOUR_API_ID

Testing Your CORS Configuration

For comprehensive testing instructions including curl commands, browser DevTools usage, and troubleshooting common CORS errors, see the CORS Testing Guide.

Additional Resources

Who’s behind this

Monsur Hossain and Michael Hausenblas

Contribute

The content on this site stays fresh thanks to help from users like you! If you have suggestions or would like to contribute, fork us on GitHub.

Buy the book

Save 39% on CORS in Action with promotional code hossainco at manning.com/hossain