CORS gets weird fast when you move from app code into infrastructure. In Pulumi, that usually means you’re not “fixing a header bug” — you’re wiring behavior across buckets, CDNs, API gateways, Lambda responses, and sometimes the browser cache too.
I’ve seen teams burn hours changing app code when the real problem was an S3 bucket CORS rule, an API Gateway preflight route, or CloudFront stripping Origin from the cache key.
This guide is the practical version: what to set, where to set it, and copy-paste Pulumi examples.
Quick CORS refresher for infra people
CORS is the browser asking:
- Can
https://app.example.comcallhttps://api.example.com? - Which methods are allowed?
- Which request headers can the frontend send?
- Which response headers can JavaScript read?
- Can credentials be included?
The core response headers you’ll deal with:
Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-HeadersAccess-Control-Allow-CredentialsAccess-Control-Expose-HeadersAccess-Control-Max-Age
And for preflight requests, the browser sends OPTIONS with:
OriginAccess-Control-Request-MethodAccess-Control-Request-Headers
If your infra doesn’t answer that preflight correctly, your app never reaches the actual API.
The rule that breaks most deployments
If you use credentials, you cannot use Access-Control-Allow-Origin: *.
That means cookies, HTTP auth, and fetch(..., { credentials: "include" }) require a specific origin like:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Not this:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Browsers reject it.
A real-world header example
GitHub’s API is a good example of a public API keeping CORS simple:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That Expose-Headers list matters. Without it, browser JavaScript can’t read most custom headers, even if they’re present in the response.
If your frontend needs rate limit info or pagination links, you need to expose those headers explicitly.
Pulumi pattern #1: S3 bucket CORS
For static assets, direct uploads, or browser access to objects, S3 CORS rules are often the source of truth.
Pulumi AWS TypeScript: S3 bucket with CORS
import * as aws from "@pulumi/aws";
const bucket = new aws.s3.BucketV2("assets", {
bucket: "myapp-assets-prod",
});
const bucketCors = new aws.s3.BucketCorsConfigurationV2("assets-cors", {
bucket: bucket.id,
corsRules: [
{
allowedHeaders: ["*"],
allowedMethods: ["GET", "HEAD"],
allowedOrigins: ["https://app.example.com"],
exposeHeaders: ["ETag"],
maxAgeSeconds: 3600,
},
],
});
For browser uploads to S3
const uploadCors = new aws.s3.BucketCorsConfigurationV2("upload-cors", {
bucket: bucket.id,
corsRules: [
{
allowedHeaders: ["content-type", "x-amz-date", "authorization", "x-amz-security-token"],
allowedMethods: ["PUT", "POST"],
allowedOrigins: ["https://app.example.com"],
exposeHeaders: ["ETag"],
maxAgeSeconds: 300,
},
],
});
My advice: don’t default to allowedHeaders: ["*"] unless you actually need it. For uploads it’s common, but tighter is better.
Pulumi pattern #2: API Gateway HTTP API with CORS
If you’re using API Gateway v2 HTTP APIs, AWS can manage a lot of CORS for you. This is usually the least painful option.
import * as aws from "@pulumi/aws";
const api = new aws.apigatewayv2.Api("http-api", {
protocolType: "HTTP",
corsConfiguration: {
allowCredentials: true,
allowHeaders: ["content-type", "authorization"],
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowOrigins: ["https://app.example.com"],
exposeHeaders: ["etag", "x-ratelimit-remaining"],
maxAge: 3600,
},
});
This is good for standard API use. But there’s a catch: your backend still needs to behave consistently for non-preflight responses. I’ve seen people assume API Gateway “handles CORS” and then wonder why their Lambda response is still blocked. The preflight may be fine while the actual GET response lacks matching headers.
Pulumi pattern #3: Lambda function URLs with CORS
Lambda Function URLs are great for quick APIs, and Pulumi makes them easy to wire up.
import * as aws from "@pulumi/aws";
const fn = new aws.lambda.Function("apiFn", {
role: "...",
runtime: "nodejs20.x",
handler: "index.handler",
code: new pulumi.asset.AssetArchive({
".": new pulumi.asset.FileArchive("./dist"),
}),
});
const url = new aws.lambda.FunctionUrl("apiFnUrl", {
functionName: fn.name,
authorizationType: "NONE",
cors: {
allowCredentials: true,
allowHeaders: ["content-type", "authorization"],
allowMethods: ["GET", "POST", "OPTIONS"],
allowOrigins: ["https://app.example.com"],
exposeHeaders: ["etag", "x-request-id"],
maxAge: 86400,
},
});
Still, your function should return headers consistently for actual responses if you want predictable behavior.
Example Lambda handler:
export const handler = async () => {
return {
statusCode: 200,
headers: {
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Expose-Headers": "ETag, X-Request-Id",
"Content-Type": "application/json",
"ETag": "abc123",
"X-Request-Id": "req-123",
},
body: JSON.stringify({ ok: true }),
};
};
Pulumi pattern #4: API Gateway REST API mock OPTIONS
If you’re stuck on the older REST API, you often need to build preflight handling yourself. It’s more verbose and more annoying.
Here’s the shape of it in Pulumi:
import * as aws from "@pulumi/aws";
const api = new aws.apigateway.RestApi("restApi");
const resource = new aws.apigateway.Resource("items", {
restApi: api.id,
parentId: api.rootResourceId,
pathPart: "items",
});
const optionsMethod = new aws.apigateway.Method("items-options", {
restApi: api.id,
resourceId: resource.id,
httpMethod: "OPTIONS",
authorization: "NONE",
});
const optionsIntegration = new aws.apigateway.Integration("items-options-int", {
restApi: api.id,
resourceId: resource.id,
httpMethod: optionsMethod.httpMethod,
type: "MOCK",
requestTemplates: {
"application/json": '{"statusCode": 200}',
},
});
const optionsMethodResponse = new aws.apigateway.MethodResponse("items-options-resp", {
restApi: api.id,
resourceId: resource.id,
httpMethod: optionsMethod.httpMethod,
statusCode: "200",
responseParameters: {
"method.response.header.Access-Control-Allow-Origin": true,
"method.response.header.Access-Control-Allow-Methods": true,
"method.response.header.Access-Control-Allow-Headers": true,
},
});
const optionsIntegrationResponse = new aws.apigateway.IntegrationResponse("items-options-int-resp", {
restApi: api.id,
resourceId: resource.id,
httpMethod: optionsMethod.httpMethod,
statusCode: optionsMethodResponse.statusCode,
responseParameters: {
"method.response.header.Access-Control-Allow-Origin": "'https://app.example.com'",
"method.response.header.Access-Control-Allow-Methods": "'GET,POST,OPTIONS'",
"method.response.header.Access-Control-Allow-Headers": "'Content-Type,Authorization'",
},
});
I don’t love this stack for greenfield work. If you can choose HTTP API instead of REST API, do that.
CloudFront gotcha: cache the right thing
If CloudFront sits in front of your API or S3 origin, CORS can fail because the CDN caches one origin’s response and serves it to another.
You need to think about:
- forwarding the
Originheader - including
Originin the cache key when appropriate - not caching broken preflight responses forever
A minimal Pulumi example using a managed origin request policy and cache policy is usually better than trying to hand-roll all headers, but if you’re doing custom policies, make sure Origin is accounted for.
If CORS looks random, CDN caching is one of my first suspects.
Exposing headers your frontend actually needs
A very common miss: the response contains the header, DevTools shows it, but fetch() can’t read it.
That means you forgot Access-Control-Expose-Headers.
Example backend response:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Expose-Headers: ETag, Link, X-RateLimit-Remaining
ETag: "abc"
Link: </items?page=2>; rel="next"
X-RateLimit-Remaining: 42
Then in frontend code:
const res = await fetch("https://api.example.com/items");
console.log(res.headers.get("etag"));
console.log(res.headers.get("link"));
console.log(res.headers.get("x-ratelimit-remaining"));
Without Expose-Headers, those reads often return null.
GitHub’s API gets this right by exposing a long list of useful operational headers.
Dynamic origin allowlists
If you have multiple environments, don’t hardcode one origin. Use Pulumi config.
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
const config = new pulumi.Config();
const allowedOrigins = config.requireObject<string[]>("allowedOrigins");
const api = new aws.apigatewayv2.Api("api", {
protocolType: "HTTP",
corsConfiguration: {
allowCredentials: true,
allowHeaders: ["content-type", "authorization"],
allowMethods: ["GET", "POST", "OPTIONS"],
allowOrigins: allowedOrigins,
exposeHeaders: ["etag"],
maxAge: 3600,
},
});
Example config:
pulumi config set --path 'allowedOrigins[0]' https://app.example.com
pulumi config set --path 'allowedOrigins[1]' https://staging.example.com
This is much cleaner than branching in code per stack.
Testing CORS without guessing
I usually test CORS in three layers:
curlfor raw headers- browser DevTools for preflight + actual request behavior
- a header inspection tool when I want a quick external check
Example preflight test:
curl -i -X OPTIONS https://api.example.com/items \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,authorization"
Example actual request test:
curl -i https://api.example.com/items \
-H "Origin: https://app.example.com"
If you want a quick way to inspect what your endpoint is really returning, HeaderTest is handy.
Common Pulumi CORS mistakes
1. Setting CORS only in app code
If S3, API Gateway, or CloudFront is in the path, infra may need its own CORS config.
2. Using * with credentials
Browsers will reject it.
3. Forgetting OPTIONS
Preflight needs a valid response path.
4. Missing Access-Control-Expose-Headers
Your frontend can’t read custom headers otherwise.
5. Allowing too much in production
* origins and * headers are easy, but they’re lazy defaults. Tighten them unless you’re building a truly public unauthenticated API.
6. Ignoring non-CORS security headers
CORS is not a security boundary by itself. If you’re also hardening browser-facing responses, CSP and related headers matter too. There’s a good reference at csp-guide.com.
Sensible defaults
If I’m wiring a normal SPA to an authenticated API, I usually start here:
- specific allowed origins, never
* - methods:
GET, POST, PUT, PATCH, DELETE, OPTIONS - headers:
content-type, authorization - credentials:
trueonly if I really need cookies/auth at browser level - expose headers: only what the frontend reads
- max age: 300 to 3600 while iterating, maybe higher later
That gets you a setup that works without being reckless.
CORS in Pulumi is mostly about knowing which resource owns the browser contract. Once you identify that layer — S3, API Gateway, Lambda URL, CloudFront, or some combination — the fix is usually straightforward. The painful part is when two layers both think they’re in charge.