Issue #1051
You build an API on API Gateway backed by a Lambda, deploy it, then call it from your web app. The request fails before it even returns data:
Access to fetch at 'https://api.example.com/items' from origin
'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
The API works fine in Postman or curl. It only breaks in the browser. That is the moment most people reach for defaultCorsPreflightOptions in their CDK stack, redeploy, and find the error is still there.
The reason is that CORS for a Lambda-backed API has two parts, and the CDK option only covers one of them.
Why the preflight alone is not enough
When you connect API Gateway to a Lambda with proxy integration, the gateway becomes a pass-through. It wraps the incoming request into an event, hands it to your function, and sends whatever your function returns straight back to the browser. It does not rewrite the response or add headers to it.
defaultCorsPreflightOptions configures the OPTIONS preflight request, which the browser sends ahead of certain cross-origin calls. That part is handled by API Gateway itself through a mock integration, so the CDK can set it up for you. But the browser also checks the actual GET or POST response for an Access-Control-Allow-Origin header. Under proxy integration, only your Lambda can put it there.
So you need both: the preflight from the CDK, and the header from the function.
The preflight in CDK
Here is a small stack in TypeScript. It defines one Lambda and one resource, and turns on the preflight for that resource.
import { Stack, StackProps } from 'aws-cdk-lib'
import { Construct } from 'constructs'
import { RestApi, LambdaIntegration, Cors } from 'aws-cdk-lib/aws-apigateway'
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'
const ALLOWED_ORIGIN = 'https://app.example.com'
export class ApiStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props)
const itemsFunction = new NodejsFunction(this, 'ItemsFunction', {
entry: 'src/items.ts',
environment: {
ALLOWED_ORIGIN,
},
})
const api = new RestApi(this, 'Api')
const items = api.root.addResource('items', {
defaultCorsPreflightOptions: {
allowOrigins: [ALLOWED_ORIGIN],
allowMethods: ['GET', 'OPTIONS'],
allowHeaders: ['Content-Type'],
},
})
items.addMethod('GET', new LambdaIntegration(itemsFunction))
}
}
The allowOrigins value must match the browser origin exactly. https://app.example.com and https://app.example.com/ are different to the CORS check, and the trailing slash version will never match. Pass the same value into the function as an environment variable so both halves agree on a single source of truth.
The header in the Lambda
The handler returns the Access-Control-Allow-Origin header on its own response. With proxy integration the response is a plain object with statusCode, headers, and body, so adding a header is a single line.
import { APIGatewayProxyHandler } from 'aws-lambda'
export const handler: APIGatewayProxyHandler = async () => {
const items = [{ id: 1, name: 'First' }]
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN!,
},
body: JSON.stringify(items),
}
}
If you have several endpoints, pull the header into a small helper so you do not repeat it, and so error responses carry it too. The browser needs the header on failures as well, otherwise a real error shows up as a CORS error and hides the actual cause.
const cors = { 'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN! }
const ok = (body: unknown) => ({
statusCode: 200,
headers: { 'Content-Type': 'application/json', ...cors },
body: JSON.stringify(body),
})
const fail = (statusCode: number, message: string) => ({
statusCode,
headers: { 'Content-Type': 'application/json', ...cors },
body: JSON.stringify({ message }),
})
Non-proxy integration
If you use non-proxy integration instead, the trade-off flips. API Gateway sits in the middle and lets you map response headers through MethodResponse and IntegrationResponse, so you can add the CORS header in the CDK and keep your Lambda unaware of it.
You declare the header in two spots. The integration response sets the value, and the method response declares that the header exists on the way out.
const integration = new LambdaIntegration(itemsFunction, {
proxy: false,
integrationResponses: [
{
statusCode: '200',
responseParameters: {
'method.response.header.Access-Control-Allow-Origin': `'${ALLOWED_ORIGIN}'`,
},
},
],
})
items.addMethod('GET', integration, {
methodResponses: [
{
statusCode: '200',
responseParameters: {
'method.response.header.Access-Control-Allow-Origin': true,
},
},
],
})
The single quotes around the origin in the integration response are required. API Gateway treats the value as a mapping expression, so a static string has to be wrapped in quotes to be read literally. With the header handled here, the Lambda returns just the payload, not a statusCode and headers wrapper.
export const handler = async () => {
return [{ id: 1, name: 'First' }]
}
This moves the header into infrastructure, at the cost of writing the response mappings. For most Lambda APIs the proxy setup is simpler, and returning the header from the function is the smaller change.
The rule to remember is that the preflight and the actual response are answered in two different places. Configure both, keep the origin identical on each side, and the browser stops complaining.
Start the conversation