If you build with SST long enough, you’ll eventually hit the classic wall:
“Blocked by CORS policy”
And the annoying part is that SST usually makes the happy path feel simple. Then one custom header, one cookie-based auth flow, or one frontend deployed to a different domain later, and you’re deep in browser errors that barely explain what’s actually wrong.
Here are the CORS mistakes I see most often in SST projects, plus the fixes that actually work.
Mistake 1: Assuming cors: true means “done forever”
A lot of SST examples make CORS look like a one-line config problem. Sometimes it is. Usually it isn’t.
For an API in SST, you might start with something like this:
new sst.aws.ApiGatewayV2("Api", {
cors: true,
});
That’s fine for basic public APIs. But the second your frontend sends credentials, custom headers, or methods beyond simple GET/POST cases, the defaults stop being enough.
The fix is to be explicit.
new sst.aws.ApiGatewayV2("Api", {
cors: {
allowOrigins: ["https://app.example.com"],
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
exposeHeaders: ["ETag", "Link", "Location"],
allowCredentials: true,
maxAge: "1 day",
},
});
That config is boring, which is exactly what you want. CORS should be predictable.
Mistake 2: Using * with credentials
This one breaks a lot of login flows.
If your frontend sends cookies or uses fetch(..., { credentials: "include" }), you cannot use:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Browsers reject that combination.
I still see people copy the wildcard pattern from public APIs and then wonder why auth fails. GitHub’s API is a good example of when * is valid because it’s designed for broad public access:
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, Warning
That works because GitHub isn’t trying to allow browser credentials from every origin on earth.
If your SST app uses session cookies, set the exact origin:
new sst.aws.ApiGatewayV2("Api", {
cors: {
allowOrigins: ["https://app.example.com"],
allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
allowCredentials: true,
},
});
And from the frontend:
await fetch("https://api.example.com/user", {
method: "GET",
credentials: "include",
});
If you need multiple environments, whitelist them explicitly:
const origins = [
"http://localhost:3000",
"https://staging.example.com",
"https://app.example.com",
];
Mistake 3: Forgetting preflight requests exist
The browser doesn’t just send your real request. For many cross-origin requests, it sends an OPTIONS preflight first.
That happens when you use:
Authorizationheaders- JSON
Content-Typelikeapplication/json - non-simple methods like
PUT,PATCH,DELETE - custom headers
If your SST API doesn’t answer the preflight correctly, the actual request never happens.
Typical browser error:
Request header field authorization is not allowed by Access-Control-Allow-Headers
That means your preflight response is missing the header name the browser asked for.
Fix it by matching reality, not guesses:
new sst.aws.ApiGatewayV2("Api", {
cors: {
allowOrigins: ["https://app.example.com"],
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: [
"Content-Type",
"Authorization",
"X-Api-Key",
"X-Requested-With",
],
},
});
If your frontend sends Authorization, put Authorization in allowHeaders. Don’t hope AWS will infer it.
Mistake 4: Setting CORS in Lambda but forgetting API Gateway owns the response
This is a common SST trap because people mix infrastructure-level CORS config with Lambda-returned headers.
They write a handler like this:
export async function handler() {
return {
statusCode: 200,
headers: {
"Access-Control-Allow-Origin": "*",
},
body: JSON.stringify({ ok: true }),
};
}
Then they also configure CORS on the API. Now they’ve got two places trying to solve the same thing, and debugging gets messy fast.
My rule: if API Gateway in SST is handling CORS, let it handle CORS. Don’t manually inject CORS headers from Lambda unless you have a very specific reason.
Use Lambda headers for app behavior:
export async function handler() {
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
"ETag": '"abc123"',
},
body: JSON.stringify({ ok: true }),
};
}
Use the SST API config for browser access policy.
That separation saves a lot of time.
Mistake 5: Not exposing response headers the frontend needs
This one is subtle because the request succeeds, but JavaScript still can’t read some headers.
Browsers only expose a limited set of response headers to frontend code unless you explicitly allow more with Access-Control-Expose-Headers.
Say your Lambda returns pagination or caching data:
export async function handler() {
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
"ETag": '"users-v42"',
"Link": '</users?page=2>; rel="next"',
"X-RateLimit-Remaining": "57",
},
body: JSON.stringify([{ id: 1, name: "Ada" }]),
};
}
Your frontend might try this:
const res = await fetch("https://api.example.com/users");
console.log(res.headers.get("ETag"));
console.log(res.headers.get("Link"));
And get null.
Why? Because you didn’t expose them.
Fix:
new sst.aws.ApiGatewayV2("Api", {
cors: {
allowOrigins: ["https://app.example.com"],
allowMethods: ["GET", "OPTIONS"],
exposeHeaders: ["ETag", "Link", "X-RateLimit-Remaining"],
},
});
GitHub does this well. Their exposed headers include useful operational data like ETag, Link, Retry-After, and multiple rate-limit headers. That’s a solid real-world pattern.
Mistake 6: Breaking localhost by hardcoding production origins only
Everyone remembers production. Everyone forgets local dev at least once.
Then you get the weird situation where the deployed app works but http://localhost:3000 fails all day.
I usually centralize allowed origins:
const stage = $app.stage;
const allowOrigins =
stage === "production"
? ["https://app.example.com"]
: ["http://localhost:3000", "https://staging.example.com"];
new sst.aws.ApiGatewayV2("Api", {
cors: {
allowOrigins,
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
allowCredentials: true,
},
});
Be strict in prod, flexible in non-prod. That’s a sane compromise.
Mistake 7: Forgetting CORS on error responses
This one is nasty because success responses work, but failures show up as generic CORS errors. The API is returning a real 401 or 500, but the browser hides it because the response doesn’t satisfy CORS.
If you rely on API Gateway CORS config, this is usually handled for you. If you manually generate responses somewhere in the chain, make sure errors also get the right CORS treatment.
For example, in custom Lambda handling:
export async function handler(event: any) {
try {
throw new Error("Nope");
} catch {
return {
statusCode: 401,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ error: "Unauthorized" }),
};
}
}
If your infrastructure isn’t adding CORS consistently, the browser reports this like a CORS failure instead of exposing the 401.
That leads people to debug the wrong thing.
Mistake 8: Treating CORS like security
CORS is a browser access rule. It is not authentication. It is not authorization. It does not protect your API from server-to-server requests, curl, or bots.
I still hear versions of “we’re safe because only our frontend origin is allowed.” No. That only limits what browsers will expose to scripts running on other origins.
If your API needs protection, use real auth and proper security headers. If you’re working on broader browser hardening beyond CORS, that’s where things like CSP matter. The official AWS docs and a focused resource like CSP Guide are worth keeping in your toolbox.
But don’t confuse that with CORS.
Mistake 9: Debugging from the browser only
Browser console errors are useful, but they’re often too vague. I prefer checking the actual preflight and response headers directly.
What I want to verify:
- Does the
OPTIONSresponse includeAccess-Control-Allow-Origin? - Does it include the exact requested method?
- Does it include the exact requested headers?
- If credentials are involved, is the origin explicit and
Access-Control-Allow-Credentials: truepresent? - Are needed response headers listed in
Access-Control-Expose-Headers?
For SST, most CORS bugs come down to one mismatch between what the browser asked for and what API Gateway allowed.
A practical SST CORS baseline
If you want a sane starting point for a frontend app talking to an SST API, this is a decent baseline:
const origins = [
"http://localhost:3000",
"https://app.example.com",
];
new sst.aws.ApiGatewayV2("Api", {
cors: {
allowOrigins: origins,
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
exposeHeaders: ["ETag", "Link", "Location"],
allowCredentials: true,
maxAge: "1 day",
},
});
And keep your Lambda focused on application logic:
export async function handler() {
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
"ETag": '"v1"',
},
body: JSON.stringify({ ok: true }),
};
}
That split is clean. API Gateway handles CORS policy. Lambda handles business behavior.
If your SST app is throwing CORS errors, I’d start with three questions:
- Are you using credentials with a wildcard origin?
- Did you allow every header and method the browser preflight asks for?
- Are you trying to read response headers you never exposed?
Most of the time, it’s one of those.