CORS in Serverless and Edge Functions
Serverless changes the CORS equation in one fundamental way: there is no persistent server, no middleware stack that runs before every request, no place to bolt on a CORS handler and forget about it. Every function invocation starts from scratch, and if you forget to handle CORS in even one function, that's the one your frontend will call at 2 AM when you're on-call.
The good news is that several serverless platforms have added CORS configuration at the infrastructure level. The bad news is that every platform does it differently, some of them do it almost right (which is worse than doing it wrong), and you'll likely need to combine infrastructure config with in-function headers to get proper behavior.
Let's work through every major platform.
AWS Lambda + API Gateway
AWS has at least four different ways to put an HTTP endpoint in front of a Lambda function, and each one handles CORS differently. This is the kind of thing that makes you understand why AWS consultants charge what they charge.
API Gateway REST API (v1)
The original API Gateway (REST API type) has a built-in "Enable CORS" button in the console. Clicking it does three things:
- Creates an
OPTIONSmethod on the resource. - Adds mock integration that returns CORS headers.
- Adds
Access-Control-Allow-Originto the method response of your actual methods.
It sounds helpful. In practice, the console-generated configuration is fragile and doesn't handle all cases. Here's what it sets up behind the scenes:
OPTIONS /api/data → Mock Integration
Method Response 200:
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Origin: *
Problems with the console approach:
- It uses
*for the origin, which won't work with credentials. - If you redeploy your API, the CORS configuration sometimes gets reset.
- It only adds CORS headers to success responses — if your Lambda throws an error and API Gateway returns a 5xx, no CORS headers.
- You can only set one static value for each header in the method response.
The correct approach for REST APIs is to return CORS headers from your Lambda function itself:
import json
def handler(event, context):
origin = event.get('headers', {}).get('origin', '')
allowed_origins = {
'https://app.example.com',
'https://staging.example.com',
}
cors_origin = origin if origin in allowed_origins else ''
# Handle preflight
if event['httpMethod'] == 'OPTIONS':
return {
'statusCode': 204,
'headers': {
'Access-Control-Allow-Origin': cors_origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
'Vary': 'Origin',
},
'body': '',
}
# Your actual logic
try:
data = {"message": "hello from Lambda"}
return {
'statusCode': 200,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': cors_origin,
'Access-Control-Allow-Credentials': 'true',
'Vary': 'Origin',
},
'body': json.dumps(data),
}
except Exception as e:
# CORS headers even on errors — this is critical
return {
'statusCode': 500,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': cors_origin,
'Access-Control-Allow-Credentials': 'true',
'Vary': 'Origin',
},
'body': json.dumps({'error': str(e)}),
}
Notice how every response path — success, error, and preflight — includes CORS headers. In a serverless function, there's no middleware to catch the cases you forget. If your error handler doesn't include CORS headers, the frontend gets a CORS error instead of a useful error message.
API Gateway HTTP API (v2)
The newer HTTP API has first-class CORS configuration that actually works well:
AWS Console → API Gateway → Your HTTP API → CORS
Or via AWS CLI:
aws apigatewayv2 update-api \
--api-id abc123 \
--cors-configuration \
AllowOrigins="https://app.example.com,https://staging.example.com",\
AllowMethods="GET,POST,PUT,DELETE,OPTIONS",\
AllowHeaders="Content-Type,Authorization,X-Request-ID",\
ExposeHeaders="X-Request-ID,X-RateLimit-Remaining",\
AllowCredentials=true,\
MaxAge=86400
HTTP API v2 handles preflight automatically at the API Gateway level — your Lambda function never even sees the OPTIONS request. This is a significant improvement over REST API v1. The CORS headers are also added to error responses generated by the gateway itself (like 429 rate-limit responses).
Gotcha: If your Lambda function also returns CORS headers, you'll get duplicates. If you use the API Gateway v2 CORS configuration, remove CORS headers from your Lambda response. Or vice versa — pick one, not both.
Another gotcha: The AllowOrigins field in HTTP API v2 supports * but not
regex patterns. If you need to match dynamic subdomains, you're back to handling
CORS in the Lambda function.
Lambda Function URLs
Lambda Function URLs (introduced in 2022) are the simplest way to put an HTTP endpoint on a Lambda function without API Gateway:
aws lambda create-function-url-config \
--function-name my-api-function \
--auth-type NONE \
--cors '{
"AllowOrigins": ["https://app.example.com"],
"AllowMethods": ["GET", "POST", "PUT", "DELETE"],
"AllowHeaders": ["Content-Type", "Authorization"],
"ExposeHeaders": ["X-Request-ID"],
"AllowCredentials": true,
"MaxAge": 86400
}'
Lambda Function URLs handle preflight automatically and add CORS headers to all responses, including errors. The configuration is clean. The main limitation is that you can't use a custom domain without CloudFront in front.
Gotcha: If you configure CORS on the Function URL and return CORS headers from your function code, the Function URL configuration takes precedence for preflight responses, but your function's headers are used for actual responses. This can lead to inconsistent behavior. Choose one approach.
CDK Examples
If you're managing infrastructure with AWS CDK (and you should be), here's how to configure CORS for each API type:
HTTP API v2 with CDK:
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
const api = new apigwv2.HttpApi(this, 'MyApi', {
corsPreflight: {
allowOrigins: [
'https://app.example.com',
'https://staging.example.com',
],
allowMethods: [
apigwv2.CorsHttpMethod.GET,
apigwv2.CorsHttpMethod.POST,
apigwv2.CorsHttpMethod.PUT,
apigwv2.CorsHttpMethod.DELETE,
apigwv2.CorsHttpMethod.OPTIONS,
],
allowHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposeHeaders: ['X-Request-ID'],
allowCredentials: true,
maxAge: Duration.hours(24),
},
});
REST API v1 with CDK:
import * as apigw from 'aws-cdk-lib/aws-apigateway';
const api = new apigw.RestApi(this, 'MyApi', {
defaultCorsPreflightOptions: {
allowOrigins: ['https://app.example.com'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
allowCredentials: true,
maxAge: Duration.hours(24),
},
});
Lambda Function URL with CDK:
const functionUrl = myFunction.addFunctionUrl({
authType: lambda.FunctionUrlAuthType.NONE,
cors: {
allowedOrigins: ['https://app.example.com'],
allowedMethods: [lambda.HttpMethod.GET, lambda.HttpMethod.POST],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Request-ID'],
allowCredentials: true,
maxAge: Duration.hours(24),
},
});
Terraform Example
resource "aws_apigatewayv2_api" "my_api" {
name = "my-api"
protocol_type = "HTTP"
cors_configuration {
allow_origins = ["https://app.example.com", "https://staging.example.com"]
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allow_headers = ["Content-Type", "Authorization", "X-Request-ID"]
expose_headers = ["X-Request-ID", "X-RateLimit-Remaining"]
allow_credentials = true
max_age = 86400
}
}
CloudFlare Workers
CloudFlare Workers run at the edge, which means they're both your application server and your CDN. CORS handling is entirely manual — there's no built-in CORS configuration. You write JavaScript (or TypeScript, Rust via WASM, etc.) and handle every aspect of the HTTP request/response yourself.
This is actually liberating once you accept it.
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com',
]);
function getCorsHeaders(request: Request): Record<string, string> {
const origin = request.headers.get('Origin') || '';
if (!ALLOWED_ORIGINS.has(origin)) {
return {};
}
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Vary': 'Origin',
};
}
function handlePreflight(request: Request): Response {
const origin = request.headers.get('Origin') || '';
if (!ALLOWED_ORIGINS.has(origin)) {
return new Response(null, { status: 403 });
}
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Request-ID',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
'Vary': 'Origin',
},
});
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Handle preflight
if (request.method === 'OPTIONS') {
return handlePreflight(request);
}
const corsHeaders = getCorsHeaders(request);
try {
// Your actual logic
const url = new URL(request.url);
if (url.pathname === '/api/data' && request.method === 'GET') {
return new Response(
JSON.stringify({ message: 'hello from the edge' }),
{
headers: {
'Content-Type': 'application/json',
...corsHeaders,
},
}
);
}
return new Response(
JSON.stringify({ error: 'not found' }),
{
status: 404,
headers: {
'Content-Type': 'application/json',
...corsHeaders,
},
}
);
} catch (err) {
// CORS headers even on errors
return new Response(
JSON.stringify({ error: 'internal error' }),
{
status: 500,
headers: {
'Content-Type': 'application/json',
...corsHeaders,
},
}
);
}
},
};
The pattern here — extracting CORS headers into a helper function and spreading them into every response — is the fundamental serverless CORS pattern. You'll see it in every platform section below.
Testing with curl:
# Preflight
curl -v -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type" \
https://my-worker.my-subdomain.workers.dev/api/data
# Actual request
curl -v -H "Origin: https://app.example.com" \
https://my-worker.my-subdomain.workers.dev/api/data
CloudFlare Workers gotcha: If you're using CloudFlare Workers in front of another origin (like an S3 bucket or your own server), and that origin also sets CORS headers, you'll get duplicates. Use the Worker to strip upstream CORS headers before adding your own:
const response = await fetch(request);
const newResponse = new Response(response.body, response);
// Remove upstream CORS headers
newResponse.headers.delete('Access-Control-Allow-Origin');
newResponse.headers.delete('Access-Control-Allow-Methods');
newResponse.headers.delete('Access-Control-Allow-Headers');
// Add our own
for (const [key, value] of Object.entries(corsHeaders)) {
newResponse.headers.set(key, value);
}
return newResponse;
Vercel
Vercel supports both Edge Functions and Serverless Functions, plus a vercel.json
configuration for static CORS headers.
vercel.json Headers
For simple cases, you can add CORS headers to all responses via configuration:
{
"headers": [
{
"source": "/api/(.*)",
"headers": [
{
"key": "Access-Control-Allow-Origin",
"value": "https://app.example.com"
},
{
"key": "Access-Control-Allow-Methods",
"value": "GET, POST, PUT, DELETE, OPTIONS"
},
{
"key": "Access-Control-Allow-Headers",
"value": "Content-Type, Authorization"
},
{
"key": "Access-Control-Allow-Credentials",
"value": "true"
},
{
"key": "Vary",
"value": "Origin"
}
]
}
]
}
Limitation: This sets a single static origin. If you need dynamic origin matching, you need to handle it in the function itself.
Vercel Serverless Functions (Node.js)
// api/data.ts
import type { VercelRequest, VercelResponse } from '@vercel/node';
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com',
]);
function setCorsHeaders(req: VercelRequest, res: VercelResponse): boolean {
const origin = req.headers.origin || '';
if (ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
// Handle preflight
if (req.method === 'OPTIONS') {
if (ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
}
res.status(204).end();
return true; // Signal that we handled it
}
return false; // Continue to main handler
}
export default function handler(req: VercelRequest, res: VercelResponse) {
if (setCorsHeaders(req, res)) return;
res.status(200).json({ message: 'hello from Vercel' });
}
Vercel Edge Functions
// api/data.ts
import { NextResponse } from 'next/server';
export const config = {
runtime: 'edge',
};
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com',
]);
export default function handler(request: Request) {
const origin = request.headers.get('Origin') || '';
const isAllowed = ALLOWED_ORIGINS.has(origin);
// Handle preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: isAllowed ? {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
'Vary': 'Origin',
} : {},
});
}
const data = { message: 'hello from the edge' };
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (isAllowed) {
headers['Access-Control-Allow-Origin'] = origin;
headers['Access-Control-Allow-Credentials'] = 'true';
headers['Vary'] = 'Origin';
}
return new Response(JSON.stringify(data), { headers });
}
Netlify
Netlify has two mechanisms for CORS: the _headers file and Netlify Functions.
The _headers File
Create a _headers file in your publish directory:
/api/*
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Vary: Origin
Or in netlify.toml:
[[headers]]
for = "/api/*"
[headers.values]
Access-Control-Allow-Origin = "https://app.example.com"
Access-Control-Allow-Methods = "GET, POST, PUT, DELETE, OPTIONS"
Access-Control-Allow-Headers = "Content-Type, Authorization"
Access-Control-Allow-Credentials = "true"
Vary = "Origin"
Same limitation as Vercel's vercel.json: static origin only.
Netlify Functions
// netlify/functions/data.ts
import type { Handler, HandlerEvent, HandlerContext } from '@netlify/functions';
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com',
]);
function corsHeaders(event: HandlerEvent): Record<string, string> {
const origin = event.headers.origin || event.headers.Origin || '';
if (!ALLOWED_ORIGINS.has(origin)) {
return {};
}
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Vary': 'Origin',
};
}
const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => {
const cors = corsHeaders(event);
// Handle preflight
if (event.httpMethod === 'OPTIONS') {
return {
statusCode: 204,
headers: {
...cors,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
body: '',
};
}
try {
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
...cors,
},
body: JSON.stringify({ message: 'hello from Netlify' }),
};
} catch (err) {
return {
statusCode: 500,
headers: {
'Content-Type': 'application/json',
...cors,
},
body: JSON.stringify({ error: 'internal error' }),
};
}
};
export { handler };
Netlify gotcha: Netlify's _headers file and function-level headers can
conflict. If you set CORS headers in both places, you may get duplicates. Pick one.
Deno Deploy
Deno Deploy runs your code at the edge, similar to CloudFlare Workers. CORS handling is entirely in your code:
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com',
]);
function corsHeaders(request: Request): Headers {
const origin = request.headers.get('Origin') || '';
const headers = new Headers();
if (ALLOWED_ORIGINS.has(origin)) {
headers.set('Access-Control-Allow-Origin', origin);
headers.set('Access-Control-Allow-Credentials', 'true');
headers.set('Vary', 'Origin');
}
return headers;
}
Deno.serve(async (request: Request) => {
const origin = request.headers.get('Origin') || '';
// Handle preflight
if (request.method === 'OPTIONS' && ALLOWED_ORIGINS.has(origin)) {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
'Vary': 'Origin',
},
});
}
const cors = corsHeaders(request);
const url = new URL(request.url);
if (url.pathname === '/api/data') {
const body = JSON.stringify({ message: 'hello from Deno Deploy' });
cors.set('Content-Type', 'application/json');
return new Response(body, { headers: cors });
}
cors.set('Content-Type', 'application/json');
return new Response(
JSON.stringify({ error: 'not found' }),
{ status: 404, headers: cors }
);
});
If you're using the Fresh framework on Deno Deploy, you can create a middleware:
// routes/_middleware.ts
import { FreshContext } from "$fresh/server.ts";
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://staging.example.com",
]);
export async function handler(req: Request, ctx: FreshContext) {
const origin = req.headers.get("Origin") || "";
const isAllowed = ALLOWED_ORIGINS.has(origin);
// Handle preflight
if (req.method === "OPTIONS" && isAllowed) {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400",
"Vary": "Origin",
},
});
}
const response = await ctx.next();
if (isAllowed) {
response.headers.set("Access-Control-Allow-Origin", origin);
response.headers.set("Access-Control-Allow-Credentials", "true");
response.headers.set("Vary", "Origin");
}
return response;
}
The Shared CORS Utility Pattern
You've probably noticed that every platform section above has the same boilerplate: check the origin, build headers, handle OPTIONS, spread headers into every response. This is the serverless CORS tax — without middleware, you repeat yourself.
The fix is a shared utility. Here's a TypeScript version that works across
CloudFlare Workers, Vercel Edge Functions, Deno Deploy, and anywhere else that
uses the Request/Response Web API:
// cors.ts
export interface CorsConfig {
allowedOrigins: Set<string> | '*';
allowedMethods?: string[];
allowedHeaders?: string[];
exposedHeaders?: string[];
allowCredentials?: boolean;
maxAge?: number;
}
const DEFAULT_CONFIG: Required<CorsConfig> = {
allowedOrigins: new Set<string>(),
allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: [],
allowCredentials: false,
maxAge: 86400,
};
export function createCorsHandler(userConfig: CorsConfig) {
const config = { ...DEFAULT_CONFIG, ...userConfig };
function isOriginAllowed(origin: string): boolean {
if (config.allowedOrigins === '*') return true;
return config.allowedOrigins.has(origin);
}
function getCorsHeaders(request: Request): Record<string, string> {
const origin = request.headers.get('Origin') || '';
if (!isOriginAllowed(origin)) {
return {};
}
const headers: Record<string, string> = {
'Access-Control-Allow-Origin': config.allowedOrigins === '*' ? '*' : origin,
'Vary': 'Origin',
};
if (config.allowCredentials) {
headers['Access-Control-Allow-Credentials'] = 'true';
}
if (config.exposedHeaders.length > 0) {
headers['Access-Control-Expose-Headers'] = config.exposedHeaders.join(', ');
}
return headers;
}
function handlePreflight(request: Request): Response | null {
if (request.method !== 'OPTIONS') return null;
const origin = request.headers.get('Origin') || '';
if (!isOriginAllowed(origin)) {
return new Response(null, { status: 403 });
}
return new Response(null, {
status: 204,
headers: {
...getCorsHeaders(request),
'Access-Control-Allow-Methods': config.allowedMethods.join(', '),
'Access-Control-Allow-Headers': config.allowedHeaders.join(', '),
'Access-Control-Max-Age': String(config.maxAge),
},
});
}
function wrapResponse(request: Request, response: Response): Response {
const corsHeaders = getCorsHeaders(request);
const newResponse = new Response(response.body, response);
for (const [key, value] of Object.entries(corsHeaders)) {
newResponse.headers.set(key, value);
}
return newResponse;
}
return { getCorsHeaders, handlePreflight, wrapResponse };
}
Usage in any platform:
import { createCorsHandler } from './cors';
const cors = createCorsHandler({
allowedOrigins: new Set(['https://app.example.com', 'https://staging.example.com']),
allowCredentials: true,
exposedHeaders: ['X-Request-ID'],
});
// In your handler:
export default async function handler(request: Request): Promise<Response> {
// One-liner preflight handling
const preflight = cors.handlePreflight(request);
if (preflight) return preflight;
// Your logic
const data = { message: 'hello' };
const response = new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
});
// One-liner CORS wrapping
return cors.wrapResponse(request, response);
}
That's three lines of CORS code in your handler. The rest lives in a shared module. Every new function gets CORS support by importing the utility and adding those three lines. Is it as clean as express middleware? No. But it's the best we've got in serverless-land.
OPTIONS Handling: The Serverless Trap
In a traditional server with middleware, OPTIONS requests are handled automatically by the CORS middleware. The request never reaches your route handler. In serverless, there are three places OPTIONS can be handled:
- Infrastructure level (API Gateway, Vercel config, Netlify
_headers) — the platform responds to OPTIONS before your function runs. - Function level — your function checks for OPTIONS and returns early.
- Nowhere — and you get a CORS error.
Option 3 is distressingly common. Here's what happens: a developer writes a
serverless function that handles GET and POST. The browser sends a preflight
OPTIONS request. The function doesn't handle OPTIONS, so the platform returns
a 405 Method Not Allowed (or worse, a 404). That response has no CORS headers.
The browser blocks the actual request. The developer stares at DevTools and sees:
Access to XMLHttpRequest at 'https://api.example.com/data'
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 fix: Always handle OPTIONS. In every function. No exceptions. If you're using the shared utility pattern above, it's one line. If you're not, it's still only a few lines. There is no excuse for skipping this.
# Test that OPTIONS is handled
curl -v -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type" \
https://your-function-url.example.com/api/data
# You should see:
# < HTTP/2 204
# < access-control-allow-origin: https://app.example.com
# < access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
# < access-control-allow-headers: Content-Type, Authorization
# If you see 404, 405, or 403 without CORS headers, your function
# is not handling OPTIONS. Fix it before anything else.
Platform Comparison
| Feature | API GW v1 (REST) | API GW v2 (HTTP) | Lambda URL | CF Workers | Vercel | Netlify | Deno Deploy |
|---|---|---|---|---|---|---|---|
| Built-in CORS config | Partial | Yes | Yes | No | Static only | Static only | No |
| Auto preflight handling | With setup | Yes | Yes | No | No | No | No |
| Dynamic origins | In Lambda | No | No | In code | In code | In code | In code |
| Credentials support | In Lambda | Yes | Yes | In code | In code | In code | In code |
| CORS on errors | No | Yes | Yes | In code | In code | In code | In code |
| IaC support | CDK/TF/SAM | CDK/TF/SAM | CDK/TF | Wrangler | vercel.json | netlify.toml | — |
The pattern is clear: the more managed the platform, the less code you write — but the less control you have. CloudFlare Workers gives you total control. API Gateway v2 gives you a checkbox. Choose based on how complex your CORS requirements are.
Summary
Serverless CORS boils down to three rules:
-
Handle OPTIONS explicitly. In every function. Test it with curl. If your platform handles preflight for you (API Gateway v2, Lambda URLs), verify it actually works by testing with curl — don't trust the documentation alone.
-
Include CORS headers on every response. Success, error, 404, 500 — every single response needs CORS headers. In serverless, there's no middleware safety net. Extract a shared utility and use it everywhere.
-
Don't configure CORS in two places. If the platform handles CORS, don't also handle it in your function. If your function handles CORS, don't also configure it in the platform. Duplicate headers will ruin your day.
Follow these three rules and you'll spend your on-call shifts dealing with actual outages instead of CORS errors. Which is, admittedly, still not great — but at least it's a different kind of misery.