CORS allows any origin: what it means and how to fix it
By Obscura Security · Updated · Next.js, Express, nginx, Supabase
Short answer
If your API sends Access-Control-Allow-Origin: * or copies whatever Origin the request has, other websites can make the browser call your API and read the answers. Combined with Access-Control-Allow-Credentials: true, that includes your logged-in users' private data. Replace it with an allow-list of the exact origins you use, and remember CORS is not authentication.
CORS errors are one of the most annoying parts of building an app, and the quickest way to make one go away is to allow every origin. That fix is where the problem starts.
What CORS does and doesn’t do
Browsers stop a page on one website from reading responses from another website by default. That’s the same-origin policy. CORS is how a server relaxes it: the server sends headers such as Access-Control-Allow-Origin to say which other origins may read its responses. (An origin is the scheme, host and port, like https://app.example.com.)
Two things follow:
- CORS is enforced by the browser. A script,
curl, or another server ignores it and can call your API whatever the headers say. So it is not a login and not a permission check. - It matters when a user’s browser is carrying something that proves who they are, such as a session cookie. If
evil.comcan make the browser send that cookie to your API and read the reply,evil.comcan read the user’s private data.
Why this happens
Something blocks the frontend, so the AI tool or a forum answer suggests one of these:
// allow everything
res.setHeader("Access-Control-Allow-Origin", "*");
// "fix" the credentials error by echoing the caller back
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
The second is the dangerous one. It tells the browser that any website may read responses with the user’s cookies attached. Modern browsers default cookies to SameSite=Lax, which blocks many cross-site requests, but cookies set with SameSite=None (common when your frontend and API are on different domains) are sent.
How to check your own app
Do this against your own API only. Use a URL that returns private data when you are logged in, and send a made-up origin:
curl -i https://YOUR-API.com/api/me \
-H "Origin: https://evil.example" \
-H "Cookie: YOUR_SESSION_COOKIE"
Look at the response headers:
Access-Control-Allow-Origin: https://evil.examplemeans your server reflects any origin.Access-Control-Allow-Origin: *means it allows everyone. This is fine for public data and wrong for private data.Access-Control-Allow-Credentials: truetogether with either of the above is the serious case.- No
Access-Control-Allow-Originheader at all means the browser will block other sites. That is what you want for a private API.
Also test the preflight request that browsers send first:
curl -i -X OPTIONS https://YOUR-API.com/api/me \
-H "Origin: https://evil.example" \
-H "Access-Control-Request-Method: POST"
The fix
Allow only the exact origins you use, and compare them exactly.
Express
import cors from "cors";
const allowed = ["https://app.example.com", "https://www.example.com"];
app.use(cors({ origin: allowed, credentials: true }));
Next.js route handlers
const allowed = new Set(["https://app.example.com", "https://www.example.com"]);
function corsHeaders(origin: string | null): Record<string, string> {
const headers: Record<string, string> = { Vary: "Origin" };
if (origin && allowed.has(origin)) {
headers["Access-Control-Allow-Origin"] = origin;
headers["Access-Control-Allow-Credentials"] = "true";
headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization";
}
return headers;
}
export async function OPTIONS(req: Request) {
return new Response(null, {
status: 204,
headers: corsHeaders(req.headers.get("origin")),
});
}
Add corsHeaders(req.headers.get("origin")) to the headers of your normal responses too. If your frontend and API are on the same origin, you don’t need CORS headers at all, so remove them.
nginx
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"https://www.example.com" $http_origin;
}
server {
# ...
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary Origin always;
}
nginx leaves out a header whose value is an empty string, so origins not in the list get no CORS header.
Then secure the API itself
Whatever the CORS setting, every private route should check who is calling and what they may see. See users can see other users’ data.
Mistakes to avoid
- Matching with
endsWithorincludes.origin.endsWith("example.com")also matchesevilexample.com. Compare against a fixed list. - Allowing the origin
null. Sandboxed pages and some local files send it, and an attacker can produce it on purpose. - Reflecting the origin without
Vary: Origin. Caches may serve one origin’s headers to another. - Treating CORS as protection. A blocked browser request says nothing about the same request sent from a script.
- Leaving
localhostin the allow-list in production. Keep development origins in a development-only setting.
Common questions
- What does CORS actually protect?
- CORS tells a browser which other websites may read responses from your server. It only governs browsers. It does nothing against curl, scripts or other servers, so it can't replace a login or permission check on the API.
- Is Access-Control-Allow-Origin: * always bad?
- No. For a public API that returns public data and uses no cookies, it is normal. It becomes a problem when the API returns private data that the browser attaches credentials to, or when you depend on CORS to keep outsiders away.
- Why does the browser block my request when I use * with credentials?
- Browsers refuse to honour a wildcard origin on requests that include cookies. Some developers then 'fix' it by reflecting the request's Origin header back, which removes the protection entirely.
- My Supabase Edge Function uses * for CORS. Is that a problem?
- It is lower risk when the function authenticates with a bearer token in the Authorization header, because a malicious website can't read your users' tokens. Verify the token inside the function, and restrict the origin to your own domains where you can.
Read next
Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].