One move kills CORS errors and hides your API key
You wire up an API call. Postman returns 200. curl returns 200. The backend engineer checks the logs and says "works fine on my end." You run the same call in the browser and get a red CORS error. So you go back to the backend: "can you add my origin to the CORS headers?" They do. Next week, a new endpoint, same wall, same ask.
If you have done frontend work for a while, you have run this loop. It has a cause, and once you see it, you stop running it.
Only the browser cares about CORS
Here is the thing that explains the whole mess: of all the clients hitting that API, only the browser enforces CORS.
Postman doesn't. curl doesn't. Your backend calling another backend doesn't. Mobile apps don't. CORS is a rule the browser puts on JavaScript running in a web page, and nowhere else. So "works in Postman, fails in the browser" is not a contradiction to debug. It is the browser being a different kind of client, the one with a rule the others never had.
That changes the ask. You are not fixing a broken request. You are dealing with the one client that has an extra rule.
What CORS actually is
Two pieces.
Same-Origin Policy (SOP) is the browser's default. It stops JavaScript from reading a response that came from a different origin (scheme + host + port). You get it for free, you don't switch it on. Your frontend at app.example.com reading from api.example.com is two different origins, so SOP blocks the read by default.
CORS is how the server lifts that default for specific origins. The server that owns the response sends a header that says "I allow app.example.com to read me", and the browser then lets the JavaScript through. CORS is the server opening a door, not the server building a wall. Without CORS headers the door is closed by default; with them, the listed origins get in.
One detail does most of the work later: CORS decides whether your JavaScript can read the response, not whether the request is sent. The request still goes out. The server still processes it. CORS only decides if the browser hands the response body back to your code. Remember that.
The move: route through your own origin
Now the part that ends the loop.
The CORS error only happens because the browser is talking to a different origin. So stop talking to a different origin. Route the call through a server you control, on your own origin, and the browser is now making a same-origin request. Same-origin means SOP never triggers, which means there is no CORS to configure and no header to ask the backend for.
In development, a dev-server proxy does it:
// vite.config.js
export default {
server: {
proxy: {
'/api': { target: 'https://api.payments.com', changeOrigin: true },
},
},
}The browser calls /api/charge, same origin as your app. Vite's dev server forwards it to api.payments.com. The browser never makes a cross-origin request.
A dev proxy is gone in production, so there you need a real server in the path. Next.js gives you one as a Route Handler:
// app/api/charge/route.ts
export async function POST(req: Request) {
const body = await req.json()
const upstream = await fetch('https://api.payments.com/charge', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.PAYMENTS_KEY}` },
body: JSON.stringify(body),
})
return Response.json(await upstream.json())
}The browser calls /api/charge on your own origin. Your server calls the upstream API. The browser's request is same-origin, so no CORS.
"use server" actions and Server Components do the same thing from another angle: the upstream fetch runs on the server during the action or the render, so the browser never makes that cross-origin call at all.
You stopped asking the backend to open CORS because you stopped sending a cross-origin request.
The same move hides your API key
Look at the Route Handler again. The Authorization: Bearer ${process.env.PAYMENTS_KEY} line runs on your server. The key comes from an environment variable that stays on the server and never travels to the browser.
That is a second problem closed by the same move. If you had called the payment API straight from the component, the key would sit in the request the browser makes, in plain text in the DevTools Network tab, readable by anyone who opens it. Routed through your server, the browser's request carries no key. The key is only attached on the server-to-server hop, which the user never sees.
This holds even when there is no attacker. The browser is not a place secrets belong, not even your own. Anything the browser sends, you can read in DevTools, and a script running in your page can reach anything your JavaScript can reach. A secret in the browser is a secret already published. Routing the call server-side is how the secret stays out of the browser to begin with. Obscuring or "encrypting" a client-side key does not help, because the code that would decrypt it ships to the browser too.
So one architectural choice, the request goes through your origin, closes both holes: the browser makes a same-origin call (no CORS), and the key stays on the server (no leak).
One thing you did take on: /api/charge is now an endpoint your own users can call. You moved the trust boundary to your server, which is the right place for it, but it means your server is the one that has to authenticate the user and rate-limit the route now.
When routing through your origin isn't the answer
The move solves the case where you are calling someone else's API. Two other situations it does not cover, and mixing them in is where CORS starts to feel mystical.
You own an API that browsers you don't control have to call. If your public API is meant to be called cross-origin from other people's web apps, you can't route that through your own server. You have to set CORS on purpose, and set it tightly: allow specific origins, never * when credentials are involved.
The one making the request is an attacker. This is where "CORS decides reading, not sending" pays off. CORS does not stop an attacker's request from reaching your server, so it is not a defense against forged requests (CSRF). The stories below show exactly what it does and doesn't stop.
Deeper: how CORS plays out against a real attacker (three stories)
First, the split that decides everything: simple vs non-simple requests.
A simple request goes out with no preflight. All three must hold: the method is GET, HEAD, or POST; the headers are only standard ones; and Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/plain. This is "what an HTML form can do". The request goes out, the server processes it, and only then does the browser check Access-Control-Allow-Origin to decide whether your JavaScript may read the response.
A non-simple request (PUT/DELETE/PATCH, custom headers like Authorization, or Content-Type: application/json) sends an OPTIONS preflight first. The browser asks permission, and if the server's policy doesn't approve, the real request never goes out.
Story 1: classic CSRF (CORS does nothing). You are logged into bank.com and visit evil.com, whose page auto-submits a form:
<form action="https://bank.com/transfer" method="POST">
<input name="to" value="evil-account">
<input name="amount" value="1000">
</form>
<script>document.forms[0].submit()</script>A form POST is a simple request. No preflight. The request goes straight to bank.com/transfer, and the browser attaches your bank.com cookies. The bank sees a logged-in user asking for a transfer and, with no other defense, processes it. The bank's CORS policy is irrelevant: CORS only governs whether evil.com's JavaScript can read the response, not whether the request arrives. The real defenses are SameSite=Lax cookies, a CSRF token, and an Origin check.
Story 2: blind CSRF (CORS helps part way). Same setup, but evil.com uses fetch with Content-Type: application/json. That makes it non-simple, so a preflight fires first. If bank.com doesn't allow evil.com, the preflight fails and the real POST never goes out. If the preflight passes but the actual response is missing CORS headers, the request still reaches the server and the transfer still happens; the browser only blocks evil.com's JavaScript from reading the result. The attacker gets the request through but never sees the outcome, which turns a sure thing into a guess.
Story 3: supply chain (CORS gives false comfort). You allow trusted-site.com in your CORS policy because it's your partner. trusted-site.com loads an ad script from ad-scripts.com, and that script runs inside trusted-site.com's origin. To CORS, requests now come from trusted-site.com, which is on the allow list, so the browser lets them through. CORS only knows origins, not whether the code inside an origin is trustworthy. The defenses here are different: Subresource Integrity (integrity="sha384-...") so the browser refuses a tampered script, CSP to restrict which origins can load scripts, and allowing as few origins as possible.
Reference: CORS headers (client-sent and server-sent)
The browser fills the client headers automatically; you don't write them in your fetch.
| Header | What it carries |
|---|---|
Origin | The calling page's scheme + host + port |
Access-Control-Request-Method | Preflight only. The method the real request will use. |
Access-Control-Request-Headers | Preflight only. The non-simple headers the real request will use. |
You write the server headers in your CORS middleware.
| Header | What it controls |
|---|---|
Access-Control-Allow-Origin | Allowed origin. A single origin matching Origin, or * for any. |
Access-Control-Allow-Methods | Methods the server accepts cross-origin. |
Access-Control-Allow-Headers | Non-simple headers the server accepts. |
Access-Control-Allow-Credentials | If true, the browser attaches cookies and the response is readable across origins. Cannot be combined with * in Allow-Origin. |
Access-Control-Max-Age | Seconds the browser may cache the preflight. Per endpoint, not global. |
To send cookies on a cross-origin call, the client opts in: fetch(url, { credentials: 'include' }), or the older xhr.withCredentials = true.
The clean split
Three separate jobs that get tangled into one word, "CORS":
| Job | The move | What it stops |
|---|---|---|
| Hide secrets from the client | Route the call through your own server | Your API key showing up in the browser |
| Control who can read your responses | SOP by default, CORS to open it up | Other origins' JavaScript reading sensitive responses |
| Block forged cross-origin requests | SameSite cookie + CSRF token + Origin check | Classic CSRF on simple requests |
The practical default falls out of this: if you are the one calling an API, route it through your own origin. That sidesteps the CORS dance and keeps the key on the server in a single move. Reach for CORS configuration only when you own an endpoint that genuinely has to be read cross-origin. And don't count CORS as a CSRF defense, because the request reaches your server either way.
For the CSRF side in full, see Stopping XSS and CSRF: who's responsible for what.