Your Complete API Gateway and CORS Guide

I recently spent some quality time with some colleagues who were implementing a web app using API Gateway. It's really a very useful service, but navigating it can be challenging due to the sheer number of options available. We wound up grinding out a solution and I wanted to share my learnings, not just the "how" but also the "why" in hopes it'll help others come to some of these decisions more easily.

I will probably make this part of a series, but for this article, I want to go into the ways to enable Cross-Origin Resource Sharing or CORS in API Gateway. It's not as intuitive as it could be and depending on the sort of integration you want to use, you'll need a different implementation.

AWS CDK is my infrastructure-as-code tool of choice. I'll provided examples in TypeScript, but also show some console screenshots and walk through some generated CloudFormation. Examples are available here.

Table of Contents

API Gateway

If you're in the AWS ecosystem, you have several choices for granting external users access to your application. API Gateway is a full-featured serverless option. It is often compared to Application Load Balancer. ALB has fewer features and is not serverless, but may be cheaper for high-throughput applications. There are some good resources out there that go into this topic at depth, so I won't.

API Gateway is often used for invoking Lambda functions, but can be connected to many other AWS services as well as HTTP integrations. API Gateway offers support for request validation, throttling, transformation and various authorization mechanisms. Taking full advantage of API Gateway can do a lot to offset the higher price point but there can be a high cognitive load in doing so.


CORS is a security mechanism supported by all major web browsers. If you are dealing with web apps, you are going to contend with CORS one way or another. I have worked in environments where the CORS headers were added by some kind of ingress gateway and I've worked in environments where the headers had to be set explicitly by the applications. API Gateway supports both models, as we shall see.

I urge you to read up on best practices and make the correct choices for your application. Some of my examples use wildcards (*) for allowed domains. You'll typically want something more restrictive than that in a web application.

In brief, what we're going to need to do to support CORS is to add an HTTP OPTIONS method for each of our route and then set the Access-Control-Allow-Origin header on our responses. There are other CORS headers that can be optionally set, such as Access-Control-Allow-Credentials, Access-Control-Allow-Headers, Access-Control-Allow-Methods and Access-Control-Max-Age. Setting these headers works in exactly the same way, so I'll just focus on Access-Control-Allow-Origin.


Before we can begin any API Gateway implementation, we need to decide which API implementation we're using. AWS API Gateway first launched in 2015 and has gained features steadily since, as AWS services tend to do. In 2019, AWS announced HTTP API as a lower-cost alternative to original, which became known as REST API.

Just to editorialize for a moment, while it's not hard to keep them straight, these are bad names, since both implementations of the service use HTTP and REST and as such, the names are not at all differentiating. Oh well, never mind, AWS is known for having this problem.

To add further confusion, HTTP API isn't Version 2 of API Gateway, but there is a version 2 of the spec. Payload format version 2.0 was initially rolled out for WebSocket APIs and is now available for HTTP API but not REST API. If you want to understand the differences, the official docs do a good job of giving us a side-by-side comparison. It's clear the intent of version 2.0 is to provide a simpler format, but you should be aware of the differences if you are used to payload version 1.0

So the three API implementations provided by API Gateway are REST API (payload format 1.0), HTTP API (choose either) and Websocket API (payload format 2.0). I won't provide any information on CORS headers for WebSocket API as it isn't part of the WebSocket spec.

The official documentation explains the feature differences between HTTP API and REST API and goes well beyond the scope of this article. I will mention that HTTP API is cheaper than REST API and thus generally the guidance is to choose it if it has all the features you need.

Proxy Integration with REST API

What, you thought you'd exhausted the decision tree? We're just getting started. There's a huge section just on the different integration types for REST API alone. Here we're going to be dealing with just one of those integration patterns, the proxy integration.

Most of the API Gateway/Lambda stacks I've seen have used proxy integrations. In this integration, we pass the request object to the function handler and construct a response object in the function as well, which is then passed back via API Gateway. That doesn't mean we're talking only about lambdaliths here! In fact, in Matt Coulter's excellent article on common Lambda patterns, he implements all three patterns using proxy integrations.

To enable CORS in a proxy integration, we need to do two things:

  1. Return a response to an OPTIONS request for each route we want to enable for CORS.
  2. Return valid CORS headers from our Lambda function.

AWS CDK gives us a nice shortcut for setting those OPTIONS responses. We can use defaultCorsPreflightOptions in our RestApiProps. That might look something like this:

 const restApi = new RestApi(this, 'ProxyCorsRestApi', { defaultCorsPreflightOptions: { allowOrigins: Cors.ALL_ORIGINS, }, });

Setting this produces the following CloudFormation:

 ProxyCorsRestApiOPTIONSEF3C92C2: Type: AWS::ApiGateway::Method Properties: HttpMethod: OPTIONS ResourceId: Fn::GetAtt: - ProxyCorsRestApiB964F16B - RootResourceId RestApiId: Ref: ProxyCorsRestApiB964F16B AuthorizationType: NONE Integration: IntegrationResponses: - ResponseParameters: method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'" method.response.header.Access-Control-Allow-Origin: "'*'" method.response.header.Access-Control-Allow-Methods: "'OPTIONS,GET,PUT,POST,DELETE,PATCH,HEAD'" StatusCode: '204' RequestTemplates: application/json: '{ statusCode: 200 }' Type: MOCK MethodResponses: - ResponseParameters: method.response.header.Access-Control-Allow-Headers: true method.response.header.Access-Control-Allow-Origin: true method.response.header.Access-Control-Allow-Methods: true StatusCode: '204'

And in a nutshell, this is why I like CDK! AWS SAM also lets you cut down on your yaml quite a bit. I definitely prefer one of these options over raw CloudFormation, but any of them should do the job.

We also need to create an integration. The CDK for that is quite nice:

 const proxyIntegration = new LambdaIntegration(proxyFn); restApi.root.addMethod('get', proxyIntegration);

This will produce CloudFormation:

 ProxyCorsRestApiget33C342A8: Type: AWS::ApiGateway::Method Properties: HttpMethod: GET ResourceId: Fn::GetAtt: - ProxyCorsRestApiB964F16B - RootResourceId RestApiId: Ref: ProxyCorsRestApiB964F16B AuthorizationType: NONE Integration: IntegrationHttpMethod: POST Type: AWS_PROXY Uri: Fn::Join: - '' - - 'arn:' - Ref: AWS::Partition - :apigateway:us-east-1:lambda:path/2015-03-31/functions/ - Fn::GetAtt: - RestProxyFn04E83FA9 - Arn - /invocations

This is verbose in comparison to the CDK, but it helps us understand what exactly is happening here. In order to invoke the Lambda service, API Gateway is making an HTTP POST on the appropriate /invocations endpoint. Good to know, but I'd definitely take a SAM template over this if forced into yaml.

Setting the headers is done in the actual Lambda function. Here's a simple TypeScript example:

import { APIGatewayProxyResult } from 'aws-lambda'; export const handler = async (): Promise<APIGatewayProxyResult> => { return { body: JSON.stringify({ state: 'ok' }), headers: { 'Access-Control-Allow-Origin': '*', }, statusCode: 200, };

Pretty straightforward in that we just return an object with the headers set, but it must be done in this exact format in order for API Gateway to map the response correctly - mapping still occurs in a proxy integration, you just don't have direct control over it.

Let's give our integrated route a look in the console.

CORS-enabled route

Notice the "Integration Response" section is grayed out. Most of the other areas here don't have a lot going on. This integration pattern puts most of the work on Lambda and remains quite simple here in API Gateway.

Let's see if we can make that OPTIONS request the web browser will need. OPTIONS requests typically return an HTTP 204 response if successful.

% curl -i -X OPTIONS
HTTP/2 204
date: Tue, 20 Apr 2021 12:11:46 GMT
x-amzn-requestid: f409a183-4bed-4f3c-8a2b-d26b77665a99
access-control-allow-origin: *
access-control-allow-headers: Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent
x-amz-apigw-id: eFO4aEnZIAMFkYw=
access-control-allow-methods: OPTIONS,GET,PUT,POST,DELETE,PATCH,HEAD
x-cache: Miss from cloudfront
via: 1.1 (CloudFront)
x-amz-cf-pop: IAD89-C2
x-amz-cf-id: ubVksQvwI3Xd7O4cli00MKp6QMnVpu6ofh2aaS7Ui7Mc3fAx-7Oaqw==

And then call the GET endpoint to verify the header is present.

% curl -i
HTTP/2 200
content-type: application/json
content-length: 14
date: Tue, 20 Apr 2021 12:12:16 GMT
x-amzn-requestid: 8335bb7d-bfd0-4814-9b16-cbbef3fad49f
access-control-allow-origin: *
x-amz-apigw-id: eFO88GedIAMFcMw=
x-amzn-trace-id: Root=1-607ec51f-791687c52f7630d700b81a35;Sampled=0
x-cache: Miss from cloudfront
via: 1.1 (CloudFront)
x-amz-cf-pop: IAD89-C2
x-amz-cf-id: b-o4OnglA0hpW8ij-dGCGU1wnZjUyY6W43hJ2IDs46fZJjuQJ3r4kw== {"state":"ok"}

So it's relatively easy to set up CORS for this pattern, but the downside is you have to add the headers to each function, which could produce a lot of boilerplate in larger stacks. I would definitely seek to add some kind of middleware solution when adding CORS headers across many functions.

Custom Integration with REST API

The next pattern is a bit more complicated.

A custom integration abstracts the HTTP request and response away from our Lambda function. We will have to map any parts of the request we want our function to see and then we'll have to map its response to something API Gateway can send back to the client. This pattern maximizes the work for API Gateway to do and minimizes how much we do in Lambda. Mappings are done using Velocity Template Language (VTL).

Why choose this integration pattern? VTL isn't exactly developer-friendly, but it's powerful. Additionally, API Gateway bills per request while Lambda bills per millisecond spent in startup and execution. If you have transformations that take meaningful amounts of time on a high-throughput API, using a custom integration could save you some money.

The other reason to think about a custom integration is it opens the door to non-Lambda integrations that use the same templates. Consider an API Gateway that serves some requests to Lambda, because some compute layer is needed for them, and integrates other requests directly with DynamoDB or S3 because they are simple enough to skip the compute layer. Such an integration would need to use mapping templates for the DynamoDB or S3 integrations already, so why not use them for Lambda as well?

Perhaps that's an edge case, but it's worth considering how best to shave your AWS bill. Service integrations and VTL could mean major savings, but only if the pattern doesn't increase development complexity by too much.

To add CORS to a custom integration we will need three things:

  1. Return a response to an OPTIONS request for each route as before.
  2. Add the desired headers to our integration responses.
  3. Map the desired headers from our integration responses into our method responses.

The first part works just the same as it does in proxy integrations:

 const restApi = new RestApi(this, 'CustomCorsRestApi', { defaultCorsPreflightOptions: { allowOrigins: Cors.ALL_ORIGINS, }, });

Now we need to add the header to our integration response which means it's going to be in the CDK code.

 const customIntegration = new LambdaIntegration(customFn, { integrationResponses: [ { responseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'", }, responseTemplates: { 'application/json': JSON.stringify({ message: '$util.parseJson($input.body)', state: 'ok', }), }, statusCode: '200', }, ], passthroughBehavior: PassthroughBehavior.NEVER, proxy: false, requestTemplates: { 'application/json': JSON.stringify({ input: 'this is the input', }), }, });

This code contains a pair of request/response templates, which is one way to do custom integrations. The important part here is the responseParameter which will set the Access-Control-Allow-Origin header to a wildcard value. Note also I need to set proxy: false. CDK considers a proxy integration to be the default integration.

The third thing we'll need is to set responseParameters in the method's methodResponses.

 restApi.root.addMethod('get', customIntegration, { methodResponses: [ { statusCode: '200', responseParameters: { 'method.response.header.Access-Control-Allow-Origin': true, }, }, ], });

The boolean value here is only whether or not the parameter is required and this would still work with a value of false but it will not work if this mapping isn't in place. Note that for the sake of brevity, I've only mapped a 200 OK response. In a real application, we'd need to handle error codes in both the request and response templates and mappings. If we fail to do this, we're likely to get an error directly from the API Gateway service that will be challenging to debug.

Now that this code is in place, we'll see the OPTIONS request in our CloudFormation template, as with the proxy integration and we'll also be able to see the mappings in the GET method we've added:

 CustomCorsRestApiget57AF1EA7: Type: AWS::ApiGateway::Method Properties: HttpMethod: GET ResourceId: Fn::GetAtt: - CustomCorsRestApi3D2B9919 - RootResourceId RestApiId: Ref: CustomCorsRestApi3D2B9919 AuthorizationType: NONE Integration: IntegrationHttpMethod: POST IntegrationResponses: - ResponseParameters: method.response.header.Access-Control-Allow-Origin: "'*'" ResponseTemplates: application/json: '{"message":{"output":"$util.parseJson($input.body)"},"state":"ok"}' StatusCode: "200" PassthroughBehavior: NEVER RequestTemplates: application/json: '{"input":"this is the input"}' Type: AWS Uri: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - :apigateway:us-east-1:lambda:path/2015-03-31/functions/ - Fn::GetAtt: - CustomFnFE03B841 - Arn - /invocations MethodResponses: - ResponseParameters: method.response.header.Access-Control-Allow-Origin: true StatusCode: "200"

The upside of our custom integration is our function can basically be anything we like. We aren't bound by any particular API, but we do need to map to our templates. Here's my sample function:

interface inputEvent { input: string;
} export const handler = async (event: inputEvent): Promise<string> => { return event.input;

I defined an interface that maps to my request template and returns a string which is output in the response template. There's a lot that can be done here but that will have to be the subject of other investigations.

The console view is a bit more relevant now. If we drill down into that Integration Response, we can find the header mappings setting CORS headers for as well as the request template.

CORS Custom Integration

Let's run the same test again for OPTIONS.

% curl -i -X OPTIONS
HTTP/2 204
date: Tue, 20 Apr 2021 12:13:41 GMT
x-amzn-requestid: 4e7b8882-56d6-497a-8850-cd4b3a274968
access-control-allow-origin: *
access-control-allow-headers: Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent
x-amz-apigw-id: eFPKaHsioAMFliA=
access-control-allow-methods: OPTIONS,GET,PUT,POST,DELETE,PATCH,HEAD
x-cache: Miss from cloudfront
via: 1.1 (CloudFront)
x-amz-cf-pop: IAD89-C3
x-amz-cf-id: 8adT0FmbEfTm3wxzoK6B5ZgM2sHoxPpML2F2wSJU-X6UUjfZCI0nDw==

And on the GET endpoint.

% curl -i
HTTP/2 200
content-type: application/json
content-length: 55
date: Tue, 20 Apr 2021 12:13:46 GMT
x-amzn-requestid: bf6d4d09-cf3a-4772-bb19-fb2b0119e0ec
access-control-allow-origin: *
x-amz-apigw-id: eFPLIF_ToAMFTbQ=
x-amzn-trace-id: Root=1-607ec57a-1e08f2330043a36640aa8aac;Sampled=0
x-cache: Miss from cloudfront
via: 1.1 (CloudFront)
x-amz-cf-pop: IAD89-C3
x-amz-cf-id: dPiy6f4MhSYlvUz35vmWTFqiAWfGynZw0X_oKvrdWSWZA-nLUxWjlA== {"message":{"output":"this is the input"},"state":"ok"}

The header is set correctly!

Proxy Integration with HTTP API

It's pretty obvious that one of the goals in developing HTTP API was to simplify. This is exemplified in the fact that HTTP API only supports proxy integrations with Lambda.

That said, setting CORS for a proxy integration works differently in HTTP API than it does in REST API. Anybody transitioning from REST API to HTTP API is likely to get caught up by the change. Let's walk through the CDK code:

 const httpApi = new HttpApi(this, 'ProxyCorsHttpApi', { corsPreflight: { allowMethods: [CorsHttpMethod.ANY], allowOrigins: ['*'] }, });

This looks a lot like how it's done in REST API, however when we look at the generated CloudFormation, there's a sizable difference.

 ProxyCorsHttpApiC5DA21B9: Type: AWS::ApiGatewayV2::Api Properties: CorsConfiguration: AllowMethods: - "*" AllowOrigins: - "*" Name: ProxyCorsHttpApi ProtocolType: HTTP

That's it! You do not have to manually set OPTIONS requests or map headers. Instead the API Gateway service reads the CORS configuration and manages all of this for you. If you are describing your API Gateway in CloudFormation, you're definitely going to appreciate this innovation.

Likewise the AWS Console for API Gateway has a specific section for configuring CORS.


Just for the sake of knowledge, I tried and you can actually skip all of this and return headers from your Lambda function. That works, but then if you configure CORS in API Gateway, it'll overwrite the headers your function returns.

So let's make an OPTIONS request to our endpoint.

% curl -i -X OPTIONS
HTTP/2 204
date: Tue, 20 Apr 2021 12:15:20 GMT
apigw-requestid: eFPZ2jtpoAMEVjQ=

Now for the trick. Everything is looking good, so I'll call my endpoint.

% curl -i
HTTP/2 200
date: Tue, 20 Apr 2021 12:15:27 GMT
content-type: text/plain; charset=utf-8
content-length: 14
apigw-requestid: eFPa1hVpIAMEVRw= {"state":"ok"}

No CORS header! What happened? It turns out that unlike REST API, HTTP API will only set the header if you pass in an Origin header on the request. Let's try that again.

% curl -i -H "Origin:"
HTTP/2 200
date: Tue, 20 Apr 2021 12:16:19 GMT
content-type: text/plain; charset=utf-8
content-length: 14
access-control-allow-origin: *
apigw-requestid: eFPjFitBIAMEVIw= {"state":"ok"}

This is probably fine and correct behavior, but I'm certain it has caused stress and annoyance from developers trying to build on HTTP API. The official docs do mention CORS headers returned from Lambda will be ignored, but do not mention that the request must contain an Origin header for CORS to work.

REST API Service Integrations

As mentioned above, service integrations (meaning API Gateway invokes an AWS service directly without Lambda), follow the same pattern as custom integrations. You will need to take the same steps to enable CORS. Although I don't talk about CORS, I do have an article on integrating DynamoDB with REST API. There isn't a lot of information in the official docs on how to build out service integrations or even what services can be integrated with in this way, but think of it as API Gateway being configured to make SDK calls and you'll be somewhere in the vicinity of figuring this out.

HTTP API Service Integrations

Continuing the pattern we've seen throughout, HTTP API service integrations are simpler, but more limited. The docs limit to just five different services you can integrate with at the time of this writing. The good news if you want to use one of these integrations and need to support CORS is you'll do the exact same thing you needed to do for an HTTP API Lambda proxy integration.


It's not too hard to navigate any one of these solutions, but it might be hard to keep them straight and the documentation isn't always clear. It's helpful to be able to correlate the implementation with the API choices you're making. Once we have that down, we can stop the churn and appreciate the gains we get from this service.

I'm interested in covering authorization and request/response validation in future articles. Feel free to drop a comment if there's any topic you'd like to see explored at depth!

COVER: Published by Actualités Théâtrales J.M. (Paris) ca. 1880–1890, Public domain, via Wikimedia Commons