Fix AWS API Gateway CORS Error: Works in Postman, Fails in Browser
If your API Gateway endpoint works perfectly in Postman but throws a CORS error the second your website calls it, the fix is almost never "just enable CORS again." Postman doesn't enforce CORS at all — it's not a browser, so it skips the entire check your website is failing. The real cause is one of a short list: a missing preflight response, a Lambda function that's erroring out before it returns your headers, or a 403/401/5xx "gateway response" that skips CORS headers by design, even when your main method is configured correctly.
Why Your API Works Perfectly in Postman and Fails in Chrome
Jake had already sent the "it's live!" text to his customer before he'd actually opened the page in a browser. He'd built a little repair-status lookup tool for his phone shop — customers type in a ticket number, it hits an API Gateway endpoint, and it shows whether their screen replacement is done. He'd tested the endpoint in Postman a dozen times. Every time: a clean 200, the right JSON, no complaints.
Then he opened the actual page. Red text in the console. Access to fetch at '...' from origin 'https://logeshwaran.org' has been blocked by CORS policy. He re-ran the exact same request in Postman. Still worked. He assumed API Gateway was broken.
"Nothing's broken," Ethan told him, once he'd seen the screenshot. "Postman never asked API Gateway for permission in the first place. It's not that your CORS setup is half-working — Postman was never testing it."
Here's the part that trips almost everyone up the first time: CORS isn't something your server does — it's a rule the browser enforces on itself, for your protection, before it will hand a cross-origin response to the JavaScript on the page. CORS stands for Cross-Origin Resource Sharing, and "cross-origin" just means the page's address (your website's domain) is different from the API's address — a different domain, a different subdomain, even just a different port counts. When your JavaScript on jakesphonefix.com calls an endpoint on xyz123.execute-api.us-east-1.amazonaws.com, that's a cross-origin request, and the browser insists on checking with the server first.
Postman is a desktop application, not a browser rendering engine. It has no page, no origin, and no built-in rule that says "don't let JavaScript read a response from somewhere else." It sends the request, gets the response, shows it to you. Full stop. curl does the same thing. So does a Python script using the requests library. None of them are lying to you when they say "200 OK" — they're just not the audience the CORS rule was written for.
♂️ Jake's Reality Check
"So the API isn't broken. Postman lied to me for two hours."
Postman didn't lie — it just wasn't asked the same question your browser is asking. Postman confirms your API returns data. It tells you nothing about whether a browser is allowed to hand that data to your JavaScript. Those are two different tests, and only one of them matters for a website.
- πͺ£ What Is Amazon S3? — cloud storage, the service everything else leans on.
- π» What Is Amazon EC2? — renting computers by the hour: the heart of the cloud.
- π½ What Is Amazon EBS? — the "hard drive" your cloud computer uses, and why it is not inside it.
- π What Is AWAS IAM? — who may touch what: the permissions layer that keeps you safe.
- π° AWS Billing in Plain English — the money post: free plan, budgets, and the traps.
- ⚡ What Is AWS Lambda? — serverless: code that runs without any computer to manage.
- π️ What Is Amazon DynamoDB? — the serverless database: instant answers at any size, pennies a month.
- πͺ What Is Amazon API Gateway? — the front door: routes, sign-in checks, the 29-second rule, and the 3.5× pricing trap.
- π¬ What Is Amazon SQS? — the waiting line: receive-process-delete, the visibility timeout, and why duplicate delivery is a feature your code must survive.
- π’ What Is Amazon SNS? — the broadcaster: one event, many listeners, and why a delivery to nobody still counts as success.
- Full series on this for beginners
This matters for how you troubleshoot, not just for your blood pressure. If you keep re-testing in Postman after a "fix," you'll get a green light every single time, whether or not the actual problem is solved. The only tool that tells you the truth is the one enforcing the rule: your browser's own developer tools.
What a CORS Error Actually Means (And What It Doesn't)
Open your browser's developer tools (press F12, or right-click the page and choose Inspect), go to the Network tab, and reload the page or trigger the request. Click on the failed call. The exact wording of the red error tells you which of a handful of specific things is missing — and guessing instead of reading it is the single biggest reason people spend hours on a five-minute fix.
A request from a script is either "simple" or "non-simple," and API Gateway's own documentation for REST APIs draws the line here: a simple request only uses GET, HEAD, or plain-form POST, sends only a small set of ordinary headers, and carries a plain content type. Almost anything modern — a JSON body, an Authorization header, a custom header your app adds — falls outside that, which means the browser doesn't send your real request first. It sends a separate, invisible check called a preflight request, using the OPTIONS method, and it waits for that to succeed before it ever sends the real one.
Think of preflight like calling ahead to a restaurant before showing up with a large group. The browser calls first — "hey, if I show up with a POST request carrying a JSON body and an Authorization header, will you seat me?" — and only walks in with the real request if the answer is yes. If your API never answers that call, or answers it without saying yes to the right things, the browser cancels the whole visit. Your actual endpoint might be flawless. It never gets asked.
| Exact browser error text | What it's telling you | Where to look first |
|---|---|---|
| "No 'Access-Control-Allow-Origin' header is present" | The response the browser got back — from OPTIONS, or from your real method — has zero CORS headers on it. | Check the status code of that response first. A 4xx/5xx skips CORS by design; see the gateway responses section below. |
| "Response to preflight request doesn't pass access control check" | The OPTIONS call itself failed or came back without the right headers. | Your OPTIONS method/mock integration, or an authorizer blocking OPTIONS. |
| "Method [X] is not allowed by Access-Control-Allow-Methods" | The preflight succeeded, but its allowed-methods list doesn't include the method your JavaScript is actually using. | The Access-Control-Allow-Methods value on your OPTIONS response. |
| "Request header field [X] is not allowed by Access-Control-Allow-Headers" | A header your fetch/axios call sends (often Authorization or a custom one) isn't on the allowed list. |
The Access-Control-Allow-Headers value on your OPTIONS response. |
| "The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when credentials mode is 'include'" | You're sending cookies or credentials, but your origin header is a wildcard. The two are mutually exclusive. | See the wildcard-plus-credentials section below. |
Notice something about every row in that table: none of them say "your API returned the wrong data" or "your Lambda function has a bug in its logic." A CORS error is exclusively about missing or mismatched headers on the response — never about the response body. If your body is correct and the browser still blocks it, the headers are the entire story.
REST API vs. HTTP API: Why the Fix Looks Different
Before you touch any settings, find out which of the two API Gateway products you actually built. This one detail changes almost everything below it.
API Gateway offers a REST API (the original, more heavyweight option — more settings, more control, older console UI) and an HTTP API (the newer, leaner option, cheaper per request, fewer moving parts). If your console shows resources, methods, and stages with things like "Method Request" and "Integration Request" as separate settings screens, you're on a REST API. If it shows "routes" and "integrations" in a flatter, simpler layout, you're on an HTTP API.
| Behavior | REST API | HTTP API |
|---|---|---|
| Preflight OPTIONS handling | You must create the OPTIONS method yourself (or let the console generate it). | API Gateway answers preflight automatically once CORS is configured — no OPTIONS route needed. |
| Headers on your real response | Non-proxy integrations: API Gateway adds them. Proxy (Lambda) integrations: your backend must return them itself. | API Gateway adds the configured CORS headers to every response and ignores whatever your backend sends for those same headers. |
| Errors from the gateway itself (403, 401, throttling, timeouts) | Skip CORS headers unless you configure Gateway Responses for DEFAULT_4XX/DEFAULT_5XX. | Covered by the same CORS configuration as everything else — no separate step. |
| Where you configure it | Per resource/method, or the "Enable CORS" button on a resource. | One CORS configuration block on the whole API. |
Ethan's opinion here, for what it's worth: "If you're starting a new project today and don't specifically need REST API features like usage plans, request validation models, or private VPC endpoints, build an HTTP API. It's cheaper, and CORS is one setting instead of a small project." Jake didn't have that choice — his repair-lookup tool was already a REST API by the time the CORS error showed up — so both paths get the full treatment below.
Fixing the Preflight OPTIONS Request on a REST API
On a REST API, the browser's preflight OPTIONS call needs its own method — API Gateway doesn't invent one on the fly. The console's built-in "Enable CORS" button does most of this for you, but knowing what it's actually doing means you can fix it by hand when the button's output isn't quite right, which happens more often than the button's cheerful green checkmark suggests.
- In the API Gateway console, open your REST API, select the resource (for example
/repair-status), and choose Enable CORS from the resource actions. - Select every method the browser will actually call from that resource — GET, POST, whichever apply. The OPTIONS method itself must be selected too; the console adds it if it doesn't already exist.
- In Access-Control-Allow-Headers, use the standard list —
Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token— or add any custom header your JavaScript actually sends, such as a custom tracing header, spelled exactly the way your code sends it. - In Access-Control-Allow-Origin, use
*to allow every origin while you're testing, or your exact site address (https://jakesphonefix.com, no trailing slash) for production. If your requests carry cookies or credentials, you cannot use*— see the wildcard section further down. - Save, then deploy the API to its stage again. This is the step almost everyone forgets, because REST APIs don't go live until you explicitly create a new deployment — editing a method and saving it changes the definition, not what's actually running.
Under the hood, what "Enable CORS" builds is an OPTIONS method backed by a mock integration — meaning it doesn't call anything, it just returns a canned 200 response with the three CORS headers on it. If you're building this by hand instead of clicking the button, that's the shape to aim for: create the OPTIONS method, give it a mock integration, set its 200 method response to include Access-Control-Allow-Headers, Access-Control-Allow-Methods, and Access-Control-Allow-Origin, and set the integration passthrough behavior to Never so it doesn't try to interpret a request body that isn't there.
⚠️ What this actually breaks
The console's "Enable CORS" button overwrites existing header values on the resource you run it against. If you'd already hand-tuned a header list, re-running it resets that customization back to the console defaults — check the values afterward, don't assume they held.
One more REST API quirk worth knowing before it costs you an afternoon: if your API has binary media types set to */*, the OPTIONS method the console generates needs its content handling changed to convert to text, or the preflight response can come back malformed. It's an edge case, but it's exactly the kind of thing that looks like "CORS is broken" when the actual cause is a content-type setting three menus away.
Lambda Proxy Integration: Your Function Has to Say the Headers, Not API Gateway
This is where most Lambda-backed REST APIs actually go wrong, and it's counterintuitive enough that it deserves its own section. With a Lambda proxy integration — the common setup where API Gateway just hands the whole request to your function and passes whatever your function returns straight back to the caller — API Gateway does not add CORS headers to that response for you. Your function has to include them in every single response it returns, success or failure.
Here's the shape your Lambda function's response needs, for Node.js:
export const handler = async (event) => {
return {
statusCode: 200,
headers: {
"Access-Control-Allow-Origin": "https://jakesphonefix.com",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "OPTIONS,GET,POST"
},
body: JSON.stringify({ status: "ready for pickup" }),
};
};
And the same idea in Python:
import json
def lambda_handler(event, context):
return {
"statusCode": 200,
"headers": {
"Access-Control-Allow-Origin": "https://jakesphonefix.com",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "OPTIONS,GET,POST"
},
"body": json.dumps({"status": "ready for pickup"})
}
Now the part that actually explains most "it worked yesterday, it's broken today" tickets: those headers only go out if your function reaches the return statement. If your code throws an unhandled exception halfway through — a bad database connection, a missing environment variable, an unexpected null — API Gateway never sees your headers object. It sees an unhandled error and generates its own 502 response, and that 502 comes from the gateway, not your function, so it carries none of the CORS headers you so carefully wrote into your code.
To a browser, that 502 looks identical to a CORS misconfiguration: no Access-Control-Allow-Origin header, request blocked. This is exactly the trap Jake fell into a second time, three weeks after his first fix. His CORS headers were fine. His function was timing out on a slow database call and crashing before the return statement, and every timeout showed up in his browser as a CORS error with no obvious connection to "the database is slow today."
♂️ Jake's Reality Check
"I fixed this exact error two months ago. Why is it back?"
Because it's probably not the same error wearing the same words. "CORS error" in your console is often just the browser's generic way of saying "the response I got had no CORS headers" — and a crashed function, a timeout, and an actual missing header all produce that same generic message. Check the status code of the failed request before you touch any CORS setting again.
The fix is to wrap your handler's logic so that every exit path — success, expected error, and unexpected crash — returns a proper response object with the headers attached, instead of letting an exception bubble up and become a bare 502. A simple try/catch around your handler's body, with the CORS headers defined once and reused in every branch, closes this gap for good. It's worth being deliberate about this even for internal, low-traffic endpoints — an intermittent CORS error that only shows up under load, when your database connection pool is exhausted, is far harder to reproduce on demand than one that fails every time, and it's exactly the kind that erodes trust in a tool your team already relies on.
Non-Proxy and HTTP Integrations: Setting Headers in the Integration Response
If you're not using a Lambda proxy integration — you're using a non-proxy Lambda integration, or an HTTP integration pointing at your own server — the responsibility flips. API Gateway itself owns the response mapping in this setup, which means you configure the CORS headers inside API Gateway's Integration Response settings, not inside your backend code.
- Open the method (GET, POST, etc.) on the resource, and go to its Integration Response section.
- For at least the 200 response — and any other status code your backend can legitimately return — add a response header mapping for
Access-Control-Allow-Origin, with a static value like'*'or your exact site address, wrapped in single quotes. - Go to the corresponding Method Response section and declare that same header as one your method is allowed to return. API Gateway won't pass a header through the integration response unless the method response has declared it first.
- Repeat for
Access-Control-Allow-HeadersandAccess-Control-Allow-Methodsif you're handling the OPTIONS preflight the same way, rather than through the console's "Enable CORS" mock integration. - Deploy the API to its stage. Again — this step is not optional, and it's the same step people skip on the proxy side.
If you used the console's "Enable CORS" button, most of this is done automatically for the OPTIONS method, but it's worth opening the Integration Response for your actual GET or POST method afterward and confirming the header is really there. The console's automatic pass doesn't always reach every method response the way you'd expect, and a spot check here has saved more than one reader an hour of re-reading the same settings page. It's also worth remembering that each status code needs its own header mapping — a 200 with the right headers and a 400 without them will still leave the browser blocking any request that happens to return that 400, even though your "happy path" looks flawless in testing.
The Trap Almost Nobody Finds: 403s, 401s, and 5xxs Skip CORS Entirely
Here is the single most common reason a completely correct CORS setup still fails in the browser: on a REST API, the CORS headers you configured on your method only apply to responses that actually reach your method. A whole category of errors never gets that far. A wrong resource path returns a 403 "Missing Authentication Token." A rejected API key returns a 403. A failed custom authorizer returns a 401 or 500. Request validation failing on a malformed body returns a 400. Every one of these is generated by API Gateway itself, before your integration — and every one of them, by default, comes back with no CORS headers at all, because there was no method response or Lambda function involved to add them.
To a browser, and to you, this looks exactly like a CORS misconfiguration. The console shows "No Access-Control-Allow-Origin header," and the instinct is to go re-check the CORS settings you already configured correctly. But those settings were never in the request's path — the request got intercepted and answered before it ever reached the method whose CORS headers you set.
The fix lives in a separate part of the console called Gateway Responses, which is easy to miss because it's not attached to any individual resource. It's a global list of the standard error responses API Gateway can generate on its own — things like DEFAULT_4XX, DEFAULT_5XX, ACCESS_DENIED, UNAUTHORIZED, MISSING_AUTHENTICATION_TOKEN, and about a dozen more specific types.
| Gateway response type | Default status | Typical cause |
|---|---|---|
| DEFAULT_4XX | Varies | Fallback for any unspecified 4xx — wrong path, bad request, most client-side errors that never reach your code. |
| DEFAULT_5XX | Varies | Fallback for any unspecified 5xx, including integration failures and authorizer connection failures. |
| MISSING_AUTHENTICATION_TOKEN | 403 | The request hit a path or method that doesn't exist on the deployed API — a typo in the URL is the usual culprit. |
| UNAUTHORIZED / AUTHORIZER_FAILURE | 401 / 500 | A Lambda or Cognito authorizer rejected the caller, or the gateway couldn't reach the authorizer at all. |
Select DEFAULT_4XX and DEFAULT_5XX and add the same Access-Control-Allow-Origin (and, if needed, Access-Control-Allow-Headers) response header you used everywhere else. That one change means a request that fails for an unrelated reason — a bad API key, a timed-out authorizer, a plain 404 — still comes back with a header the browser can read, so your JavaScript sees "403 Forbidden" or "500 Internal Server Error" instead of a generic, unhelpful CORS block. That's not a cosmetic improvement: it's the difference between your app being able to show the user "your session expired" and just failing silently with no usable information at all.
⚠️ What this actually breaks
If you skip Gateway Responses, every authentication failure and every wrong-URL typo your users hit will present as an unreadable CORS error in the browser console, with zero indication of the real 401, 403, or 404 underneath it. Support tickets about "CORS is broken" that are actually "the API key expired" are one of the most common false leads in this entire troubleshooting process.
HTTP APIs don't have this problem in the first place — their CORS configuration applies to the whole API, gateway-generated errors included, which is one of the concrete reasons Ethan reaches for HTTP APIs by default now.
The Easier Path: Fixing CORS on an HTTP API
On an HTTP API, CORS is one configuration block attached to the whole API — not something you touch per resource, per method, or in a separate Gateway Responses screen. Once it's set, API Gateway automatically answers preflight OPTIONS requests for you, even for routes where you never explicitly created an OPTIONS route, and it applies the same allowed-origin, allowed-headers, and allowed-methods settings to every response the API sends, errors included.
From the console: open your HTTP API, go to CORS in the left navigation, and fill in the fields — allowed origins, allowed methods, allowed headers, and optionally whether credentials are allowed and how long the browser should cache the preflight answer.
From the command line, the same thing looks like this:
aws apigatewayv2 update-api \
--api-id your-api-id \
--cors-configuration AllowOrigins="https://jakesphonefix.com"
One setting worth knowing about: on an HTTP API, once CORS is turned on, API Gateway ignores any CORS headers your own backend returns and uses only the ones from the CORS configuration. That's the opposite of REST API's Lambda proxy behavior, and it trips up anyone who's built both — code you wrote specifically for REST API's proxy integration (manually adding Access-Control-Allow-Origin inside the Lambda response) is not just unnecessary on an HTTP API, it's simply overridden.
✅ Why this is the one to use
Configuring CORS at the API level, once, beats duplicating header logic across every Lambda function or every method's integration response. One place to update means one place to get wrong — and when you rotate your allowed origin later (a new custom domain, a staging site), you change it once instead of hunting through every function.
There's one specific complication if your HTTP API uses a $default route with an authorizer attached: the $default route catches every method and path you haven't explicitly defined, including OPTIONS, and an authorizer on that route will reject the browser's unauthenticated preflight call along with everything else. The documented fix is to add a dedicated OPTIONS /{proxy+} route with no authorization requirement and its own integration — that specific route outranks $default in API Gateway's routing priority, so preflight requests reach it without ever touching your authorizer.
The Wildcard-Plus-Credentials Mistake (And the Multiple-Origins Problem)
Two related mistakes account for a surprising share of "I set Access-Control-Allow-Origin and it still doesn't work" reports.
Wildcard plus credentials is invalid, by design, in every browser. If your JavaScript sends the request with credentials: "include" — meaning it's carrying cookies, or you've set Access-Control-Allow-Credentials: true because you need the browser to accept a session cookie back — the browser will refuse to accept Access-Control-Allow-Origin: * as an answer, full stop, no exceptions. This isn't an API Gateway limitation; it's the CORS specification itself, because a wildcard origin combined with credentials would let literally any website on the internet read a user's authenticated session data. If you need credentials, your Access-Control-Allow-Origin must be one specific, exact origin — not a wildcard, and not a list.
Which leads to the second mistake: there's no built-in way to list multiple exact origins in a single Access-Control-Allow-Origin header. The header only ever holds one value. If your product needs to allow both https://app.example.com and https://staging.example.com, the standard approach is to have your backend (or, on an HTTP API, the platform itself) read the incoming Origin header from the request, check it against your allowlist, and echo back that exact origin — never a hardcoded second value bolted on with a comma. On a Lambda proxy integration, that means a small lookup in your function code before you set the header. On an HTTP API, you can register more than one origin in the CORS configuration's allowed-origins list, and API Gateway does that origin-matching for you.
Automating CORS So You Stop Fixing It By Hand Every Time
If you've clicked "Enable CORS" in the console more than once for the same project, it's worth defining it in code instead, so the next deployment can't accidentally undo it. Two options cover most teams building on API Gateway.
AWS SAM — the framework for building serverless applications with CloudFormation-style templates — has a dedicated Cors property on the AWS::Serverless::Api resource. Its documented fields are AllowOrigin, AllowHeaders, AllowMethods, AllowCredentials, and MaxAge, and it generates the preflight OPTIONS mock integration for you:
MyApi:
Type: AWS::Serverless::Api
Properties:
StageName: prod
Cors:
AllowOrigin: "'https://jakesphonefix.com'"
AllowHeaders: "'Content-Type,Authorization'"
AllowMethods: "'GET,POST,OPTIONS'"
Important detail buried in SAM's own reference: this Cors property only takes effect if SAM manages your OpenAPI definition — if you're supplying your own inline OpenAPI document in DefinitionBody and also set Cors, SAM merges the two, and the property-level setting wins over whatever the OpenAPI document says for the same value. On a REST API built this way, remember that SAM's Cors block only wires up the preflight response — a Lambda proxy integration behind it still needs to return the headers itself on its real response, exactly as covered above.
AWS CDK — the Cloud Development Kit, for defining infrastructure in TypeScript, Python, or another supported language instead of YAML — exposes the same idea as a CorsOptions object, set either on the whole API with defaultCorsPreflightOptions or on one resource with addCorsPreflight. Its documented fields include allowOrigins, allowCredentials, allowHeaders, and allowMethods, matching the same header logic under a different syntax:
new apigateway.RestApi(this, 'RepairStatusApi', {
defaultCorsPreflightOptions: {
allowOrigins: ['https://jakesphonefix.com'],
allowMethods: ['GET', 'POST'],
},
});
One gotcha specific to CDK worth knowing before you hit it in production: when you set defaultMethodOptions with an authorizer at the same level as defaultCorsPreflightOptions, that authorizer can get inherited by the generated OPTIONS method along with every other method — reintroducing the exact "authorizer blocks preflight" problem covered earlier, just from infrastructure code instead of a console click. If you see it happen, explicitly set the generated OPTIONS method's authorization type back to none rather than assuming CDK excludes it automatically.
Forgot to Deploy? Browser Caching a Bad Preflight? Check This Before Anything Else
Before you dig any deeper into headers and integrations, rule out the two most boring — and most common — causes.
On a REST API, nothing you change in the console goes live until you deploy it to a stage. This is different from most web platforms, where saving a setting takes effect immediately. In API Gateway, editing a resource, a method, or an integration response changes the API's definition, but the stage — the actual live URL your browser calls — keeps running whatever was deployed last, until you explicitly create a new deployment against it. Ethan's rule of thumb: "If you've changed anything and it 'still' doesn't work, redeploy before you touch another setting. Half the time that's the whole fix."
Browsers cache preflight responses. The Access-Control-Max-Age header tells the browser how long it can skip re-sending the OPTIONS request and just reuse the last answer — and if you tested a broken configuration once, got a bad preflight response, then fixed it, your browser may keep replaying the cached "no" for the duration of that max-age window, or until you hard-refresh. When you're actively troubleshooting, open dev tools, check "Disable cache" in the Network tab, and do a full hard reload, or you'll spend time debugging a fix that's already correct.
The Other Cache: API Gateway's Own Stage Cache Can Serve a Stale Error Too
Browser caching isn't the only cache in this chain. If you've turned on API Gateway's own response caching for a stage — a paid, opt-in feature separate from anything a browser controls — API Gateway will hold on to responses for a configurable time-to-live, defaulting to 300 seconds and capped at 3,600 seconds, before checking your backend again. By default, only GET methods are cached, but a method override can extend that to others.
Here's where it bites you specifically on CORS: if a broken response — say, a 502 from a crashed Lambda function, missing your headers, from the trap covered earlier — got cached during the window when your setup was still broken, fixing the underlying bug in your code does nothing until that cached entry expires or you manually flush the cache. The browser will keep reporting the exact same CORS error, and every symptom will point at your CORS configuration, when the actual stale item is sitting in API Gateway's own cache, upstream of any header logic you've touched.
If you've made a fix that should have worked, confirmed it with curl against a fresh request, and it's still failing intermittently or only on repeat visits, check whether stage-level caching is enabled before assuming your code is still wrong. Flushing the stage's cache from the console, or simply waiting out the TTL, rules this out in under a minute.
How to Test CORS the Way a Browser Tests It
Since Postman can't tell you whether CORS is fixed, the next-best tool from your terminal is curl, sending exactly the kind of request a browser's preflight sends:
curl -v -X OPTIONS \
https://your-api-id.execute-api.us-east-1.amazonaws.com/prod/repair-status
A working response looks like this, with the CORS headers present in the reply:
< HTTP/1.1 200 OK
< Access-Control-Allow-Origin: *
< Access-Control-Allow-Headers: Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token
< Access-Control-Allow-Methods: DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT
If those three headers show up on the OPTIONS response, your preflight is solid. That still doesn't finish the job, though — repeat the same idea against your real method (a GET or POST, whichever your JavaScript actually sends), because that's the response the browser checks second, and it's the one your Lambda proxy code is responsible for. If the OPTIONS response looks perfect but your actual GET or POST comes back without Access-Control-Allow-Origin, you've isolated the problem to the real method's response, not the preflight — which is exactly the Lambda-crash pattern from earlier.
Only once both checks pass with curl should you go back to the actual browser and test the real page — and test it in more than one place. A CORS fix that works in Chrome on your laptop can still surface differently in Safari, where Intelligent Tracking Prevention treats some cross-site requests more strictly by default, or inside a mobile app's embedded webview, which sometimes enforces its own stricter rules than the phone's regular browser. None of that changes anything about API Gateway's configuration — it's the same three headers either way — but it does mean "it works on my machine" isn't the finish line if your actual users are on a mix of devices. If it still fails there and passes with curl, the difference is almost always the browser cache described above, or a difference between the exact origin your site sends and the exact origin your configuration allows — trailing slashes and http vs https both count as a mismatch.
Why "Just Install a CORS-Disabling Extension" Isn't a Fix
Somewhere in your search results while troubleshooting this, you'll run into browser extensions that promise to "disable CORS" with one click. They work, in the narrow sense that the error goes away on your machine. They also prove absolutely nothing about whether your actual users can use your site, because those extensions modify how your own browser enforces the rule — every visitor to your live website is still running an unmodified browser, still enforcing CORS exactly as strictly as before, and still getting blocked exactly the way you were before you installed the extension.
♂️ Jake's Reality Check
"A guy on a forum said installing a CORS extension fixed it for him in ten seconds."
It "fixed" it on his computer, for him, while it stayed installed — nothing more. The moment he uninstalls it, or a customer without it visits the same page, the exact same block comes right back. Treat these extensions as a way to peek at what a permissive response would look like during development, never as something you tell customers to install or as a substitute for fixing the actual response headers.
There's also a real risk hiding in the convenience: those extensions typically disable CORS enforcement for every site you visit while active, not just the one you're debugging, which strips away a protection that exists specifically to stop malicious pages from quietly reading data from your other logged-in accounts. Use them, if at all, in a disposable test profile, switched off the moment you're done — never as your daily browser, and never as a reason to skip fixing the headers for real.
The Security Reasoning Behind Not Defaulting to a Wildcard
It's tempting, once Access-Control-Allow-Origin: * finally makes the error disappear, to leave it that way permanently and move on. For a genuinely public, read-only API with no sign-in — a weather lookup, a public status page — that's a defensible, low-risk choice. For anything that returns data tied to a specific signed-in user, it's worth pausing on what the wildcard actually grants.
A wildcard origin tells every browser, for every website on the internet, that JavaScript running there is allowed to read whatever your endpoint returns to it. If your endpoint's data ever depends on who's asking — an account balance, a customer's repair ticket, anything scoped to a session — a wildcard combined with any form of credential-free "trust me, I'm logged in via some other mechanism" pattern (like an API key embedded in client-side JavaScript) means any site the user happens to visit, malicious or not, could quietly fire the same request in the background and read the response. This is precisely why the CORS specification refuses to let you combine a wildcard with Access-Control-Allow-Credentials: true in the first place — it's not an arbitrary restriction, it's the browser closing a specific, well-understood hole.
The practical takeaway: use * freely while you're building and testing, then narrow it to your exact production origin (or the small, explicit allowlist described earlier) before anything real depends on the endpoint. It costs one line to change and it's a meaningfully different risk profile.
When You've Done Everything Right and It's Still Broken
If curl confirms both your preflight and your real method are returning the right headers, the stage cache is ruled out, and the browser is still blocking the request, a small number of edge cases account for most of what's left.
Something in front of API Gateway is stripping the headers. If you've put CloudFront or AWS WAF in front of your API, check whether the cache behavior or the response handling is forwarding the Access-Control-Allow-* headers through unmodified. A CloudFront distribution configured to cache based on a limited header allowlist can silently drop headers it wasn't told to pass along, and the browser will report the same generic missing-header error either way.
Private REST APIs have their own, unrelated failure mode that looks identical from the browser. If your API is a private REST API accessed through an interface VPC endpoint, and that endpoint doesn't have private DNS enabled, requests to the friendly endpoint-specific hostname won't route correctly at all — you have to use the Route 53 alias invoke URL format instead, built from your REST API ID, your VPC endpoint ID, the region, and the stage name. That failure shows up as a connection or routing problem well before CORS ever enters the picture, but because the browser reports it the same generic way, it's easy to spend an hour rechecking headers that were never the issue.
A custom header you're relying on for authentication got blocked before your API ever saw it. The x-apigw-api-id header, used in some private API setups, triggers a preflight OPTIONS request that doesn't itself carry that header — so a request depending on it can fail to ever reach your API at the preflight stage, independent of anything you've configured for CORS.
♂️ Jake's Reality Check
"I've checked every setting three times. What if it's just never going to work?"
We can't diagnose a setup we can't see, and neither can any general guide. At this point the honest move is opening your browser's dev tools, reading the exact status code and exact header list on the failed response, and comparing that specific response against the specific configuration behind it — not re-reading generic advice a fourth time. If the status code is a 4xx or 5xx you haven't explained yet, that's where the real answer is hiding.
16 Questions Readers Ask After Fixing (or Failing to Fix) CORS on API Gateway
Why does my API work in Postman but not in my browser?
Postman is a desktop tool, not a browser, and it doesn't enforce CORS at all — there's no page, no origin, and no rule stopping it from reading any response. Only browsers run the CORS check, so testing exclusively in Postman can leave a real CORS problem completely invisible.
Is CORS a security feature of my API, or of the browser?
It's enforced entirely by the browser, on behalf of the user. Your API can choose to allow or refuse cross-origin access by sending the right headers, but the actual blocking happens client-side, inside the browser, never on your server.
Do I need to enable CORS if my frontend and API are on the same domain?
No. CORS only applies to cross-origin requests — a different domain, subdomain, port, or protocol. If your website and your API Gateway endpoint are served from the exact same origin, the browser never triggers a CORS check in the first place.
Why does the OPTIONS request return a 200 but the actual GET or POST still fails?
This almost always means your preflight is configured correctly but the real method's response isn't. On a Lambda proxy integration, this points to your function not including the CORS headers on its actual response, or crashing before it reaches the return statement.
Why does my 403 or 401 error not show any CORS headers at all?
Because that response was generated by API Gateway itself — a rejected API key, a failed authorizer, a wrong path — before your request ever reached your method's configured CORS headers. Configure Gateway Responses for DEFAULT_4XX and DEFAULT_5XX to add headers to those cases too.
Does adding Access-Control-Allow-Origin in my Lambda function fix a 500 error?
No, and this is a common false fix. If your function throws an unhandled exception, API Gateway generates its own 502 or 500 response and your code's headers never get sent. Fix the underlying error, or wrap it in a try/catch that returns a proper response with headers attached, not just the headers.
Can I use Access-Control-Allow-Origin: * with credentials (cookies or Authorization headers)?
No. Browsers reject a wildcard origin combined with credentials mode, by design, because it would let any site read authenticated data. If your request carries credentials, your allowed origin must be one exact, specific address.
How do I allow more than one origin in API Gateway CORS?
The Access-Control-Allow-Origin header can only hold one value at a time, so a comma-separated list of origins doesn't work. On an HTTP API, register multiple allowed origins in the CORS configuration and let API Gateway match them. On a REST API with a Lambda proxy, check the incoming Origin header in your code against an allowlist and echo back only the matching one.
Why did my CORS fix work in the API Gateway console test but not from my actual website?
The console's built-in test tool doesn't behave like a real browser tab and can skip the exact preflight sequence your website's JavaScript triggers. Test with curl using an actual OPTIONS request, and test in the real browser with caching disabled, rather than trusting the console's test button alone.
Do I need to redeploy my REST API after changing CORS settings?
Yes, always. On a REST API, changes to resources, methods, and integration responses don't take effect on the live URL until you create a new deployment to the stage you're calling. This is the single most common reason a correct fix appears not to work.
Why does an API key or authorizer break my preflight request?
Browsers send the OPTIONS preflight without your API key or auth token, since it's an automatic, unauthenticated check. If your OPTIONS method requires authorization, the preflight itself gets rejected before your real request is ever attempted. The OPTIONS method should not require an API key or authorizer, even if your GET or POST does.
What's the difference between fixing CORS on a REST API and an HTTP API?
On a REST API, you configure CORS per resource and method, must build or generate an OPTIONS method yourself, and must separately handle Gateway Responses for errors. On an HTTP API, one CORS configuration block covers the whole API automatically, including preflight and gateway-generated errors, with no separate OPTIONS route required.
Can API Gateway caching or a browser cache serve a stale CORS response?
Yes, and in two separate places. Browsers cache preflight responses for the duration set by Access-Control-Max-Age, and API Gateway's own stage-level response caching, if enabled, can hold a broken response for up to its configured time-to-live. Disable browser caching while testing, and check whether stage caching is enabled and needs flushing.
Does CloudFront or AWS WAF in front of my API affect CORS headers?
It can. If a CDN or firewall sits in front of API Gateway, check that it's configured to forward the Access-Control-Allow-* headers through unmodified rather than stripping headers it wasn't explicitly told to pass along.
Why does my private (VPC) REST API fail with a CORS-looking error?
If your private API's VPC endpoint doesn't have private DNS enabled, requests can fail to route correctly at all, which a browser can present as a generic blocked-request error resembling CORS. Check whether private DNS is enabled on the interface VPC endpoint, and use the Route 53 alias invoke URL format if it isn't.
How can I permanently confirm CORS is fixed without guessing?
Send a curl OPTIONS request and confirm the three Access-Control-Allow headers appear on the response, then repeat the same check against your real method (GET, POST, etc.), then finally test in the actual browser, in more than one browser if possible, with caching disabled. Only that full sequence, not a single Postman call, actually confirms the fix.
Revision note. Written September 2026, covering both REST APIs and HTTP APIs in Amazon API Gateway, including Lambda proxy and non-proxy integrations, SAM and CDK automation, and API Gateway's own stage-level response caching. This will need a fresh look if AWS changes how Gateway Responses or HTTP API CORS configuration work, so treat the console screenshots in your own account as the final word if anything here looks out of date. If you're reading this at midnight with a broken launch and a red console, take a breath — this is one of the most common walls every API Gateway project hits, and it's almost never as broken as it feels right now.
Also Read: