CORS misconfiguration: how one header lets any site read your API
A CORS misconfiguration (CORS decides which sites may read your API's responses) happens when your API lets any website read them, usually by copying the request's Origin header back with credentials on. A page on evil.example can then read a logged-in user's data. Fix it with an exact list of allowed origins. CORS is not security: tools like curl ignore it, so check logins on the server.
QUICK CHECKLIST
- Never combine origin: true with credentials: true
- Allow exact origins from a list, not regexes
- Never allow the null origin
- Send Vary: Origin when the header changes per request
- Use * only for public data with no cookies
- Check auth on the server, not with CORS
What a CORS misconfiguration is and why it matters
CORS is a browser rule. When a script on one site asks another site for data, the browser checks the response headers before it lets the script read the answer. Access-Control-Allow-Origin names the sites that may read it, and Access-Control-Allow-Credentials: true lets the script read responses to requests that were sent with cookies.
A CORS misconfiguration is a policy that names too many sites. The usual version is a server that copies the request's Origin header (the browser's note of which site the request came from) into Access-Control-Allow-Origin while credentials are on. Any website your logged-in user visits can then call your API as that user and read the result whenever the browser sends the session cookie cross-site, which is the case for SameSite=None cookies, the usual setting when the frontend and API live on different domains.
How attackers abuse a bad CORS policy
A logged-in user opens a page the attacker controls. A script on that page calls your API with credentials: "include", your server answers with a permissive header, and the script reads the response. PortSwigger's examples show attackers pulling API keys and CSRF tokens out of responses this way.
These are the usual ways the policy goes wrong:
- A reflected origin. In Express cors,
origin: truecopies the request's Origin intoAccess-Control-Allow-Origin. Addcredentials: trueand every website can read logged-in responses. OWASP calls reflecting the origin without checks very insecure. The only exception it gives is a public API meant for everyone, and that never applies when credentials are allowed. - Wildcard plus credentials. Browsers refuse
Access-Control-Allow-Origin: *when credentials are involved, so the call fails in development. Reflecting the origin makes the error go away and opens the hole. - The null origin. Browsers send
Origin: nullfrom sandboxed iframes,file:pages, some redirects and serialized data. Developers allow it for local testing, but an attacker can send it on demand from a sandboxed iframe, so it works like a wildcard. - A regex with a loose end.
/myapp\.com$/also matcheshttps://hackersmyapp.com. A check without the$matcheshttps://myapp.com.evil.example. Both patterns appear in the PortSwigger and OWASP write-ups.
A correct allowlist still trusts every site on it. If an allowed subdomain has XSS (cross-site scripting, where an attacker gets their own script to run on your page), the attacker runs script there and reads your API from it. An allowed http:// origin is weaker for the same reason: someone on the network can inject a page that sends that origin.
| Setting | What the browser does | Verdict |
|---|---|---|
| origin * with no credentials | Any site reads the response | Public data only |
| origin * with credentials | Browser rejects the response | Broken, use a list |
| origin: true with credentials | Any site reads private data | Never |
| Exact allowlist with credentials | Only listed sites read | Yes |
| Suffix regex or null allowed | Lookalike pages read it | Never |
Wildcard plus credentials fails in the browser. Reflecting the origin makes it work for everyone.
Express and Next.js: before and after
The Express cors() call with no options sends Access-Control-Allow-Origin: *. That's fine for public data. The trouble starts when a CORS error shows up in the console and someone adds credentials: true and origin: true to silence it.
import express from "express"
import cors from "cors"
const app = express()
// BAD: copies whatever Origin the browser sends
app.use(cors({ origin: true, credentials: true }))
// Also BAD: this regex matches hackersmyapp.com
// app.use(cors({ origin: /myapp\.com$/, credentials: true }))import express from "express"
import cors from "cors"
// production: ALLOWED_ORIGINS=https://myapp.com
// local .env only: ALLOWED_ORIGINS=https://myapp.com,http://localhost:3000
const allowed = (process.env.ALLOWED_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean)
const app = express()
app.use(
cors({
origin: allowed, // exact strings only
credentials: true,
})
)Next.js route handlers have no CORS middleware, so you set the headers yourself. The same rule applies: echo an origin only when it's on your list.
// app/api/me/route.ts
import { getCurrentUser } from "@/lib/auth" // your own helper
export async function GET(req: Request) {
const user = await getCurrentUser(req)
return Response.json({ email: user?.email }, {
headers: {
// BAD: reflects any origin and allows cookies
"Access-Control-Allow-Origin": req.headers.get("origin") ?? "*",
"Access-Control-Allow-Credentials": "true",
},
})
}// app/api/me/route.ts
import { getCurrentUser } from "@/lib/auth" // your own helper
const ALLOWED = new Set(["https://myapp.com", "https://www.myapp.com"])
export async function GET(req: Request) {
const user = await getCurrentUser(req)
if (!user) return new Response("Unauthorized", { status: 401 })
const origin = req.headers.get("origin")
const headers: Record<string, string> = { Vary: "Origin" }
if (origin && ALLOWED.has(origin)) {
headers["Access-Control-Allow-Origin"] = origin
headers["Access-Control-Allow-Credentials"] = "true"
}
return Response.json({ email: user.email }, { headers })
}How to fix a CORS misconfiguration, step by step
Check whether you need CORS
Same-origin calls never need it. Only add CORS when a different site or domain calls your API from a browser.
Write the origins down
List each origin exactly: scheme, host and port, such as
https://myapp.com. No wildcards, no regexes, nonull. Keep the list in an env variable likeALLOWED_ORIGINS. Addhttp://localhost:3000only in your local .env, never in production.Compare, then echo
Send
Access-Control-Allow-Originonly when the request's Origin is an exact match for an entry. For any other origin, send no CORS headers at all.Add Vary: Origin
When the header value changes per request, add
Vary: Origin. Otherwise a cache can hand one site's permission to another. The Express docs showVary: Originon their single-origin and dynamic examples.Keep * for public data only
A public endpoint with no cookies, like a product list, can use
Access-Control-Allow-Origin: *. Don't put it next to credentials or private data.Check auth on the server
Every handler that returns private data should check the session and ownership itself. See IDOR in API routes for the usual gaps.
Run the curl loop in the next section to confirm the headers changed.
CORS is not security: curl ignores it
CORS only decides whether a browser lets a page read a response. Your server still receives and handles every request. The Express cors docs say it plainly: curl, Postman and other servers ignore CORS entirely.
So a tight allowlist doesn't protect an endpoint that has no auth. Anyone can run curl https://yourapp.com/api/users and read the answer. Anyone can also set an Origin header by hand, and OWASP warns against relying on it for access control.
Simple requests, such as form-style POSTs, go out without a preflight, so a page on another site can still make your server act. PortSwigger states that CORS is no protection against CSRF, where another site makes a user's browser send a state-changing request with their cookies.
Check sessions in each handler, and see Next.js middleware authentication for why a middleware check alone can fall short. If you keep tokens in the browser, read JWT in localStorage vs cookies too.
Test it yourself
Replace the placeholders with your domain and a session cookie from your own logged-in browser (or an Authorization header if you use one).
The loop sends five Origin values: your real one, a stranger, null, and two lookalikes. curl doesn't enforce CORS, so it just shows which headers your server sends. The browser applies the rule.
for o in https://myapp.com https://evil.example null https://myapp.com.evil.example https://evilmyapp.com; do
echo "== Origin: $o"
curl -s -i https://YOUR-APP.com/api/me \
-H "Origin: $o" \
-H "Cookie: YOUR_SESSION_COOKIE" | grep -iE "^(access-control|vary)"
doneA bad result is any access-control-allow-origin line that repeats the stranger, null or lookalike origin, especially next to access-control-allow-credentials: true. A good result shows no access-control-allow-origin line for those four. Your real origin should be echoed back, with vary: Origin present.
Then check in a browser. Open DevTools on a tab for an unrelated site while you're logged in to your app, and run:
fetch("https://YOUR-APP.com/api/me", { credentials: "include" })
.then((r) => r.json())
.then(console.log)If your user data prints, the policy is open. If no data prints, look at the Network tab before you relax. A CORS error means the browser blocked the read. A 401 or empty response with an access-control-allow-origin header that matches the unrelated site means the policy is still open, and your cookies just weren't sent (SameSite=Lax/Strict or third-party cookie blocking). Treat the curl results above as the authoritative check. Some sites have a Content Security Policy that blocks this fetch, so use a local test page if that happens.
Fix prompt you can paste
Give this to Cursor or Claude Code. After you deploy the fix, you can scan the app with Leakcade.
Audit this repo for CORS misconfiguration.
Search for: the Express `cors` package (origin: true, origin: "*", regex origins, credentials: true), cors() calls with no options on routes that return private data, manual Access-Control-Allow-Origin headers in Next.js route handlers (app/**/route.ts) and middleware.ts, and any code that echoes req.headers.get("origin") or req.headers.origin back or allows "null".
For each finding, output the file path, line number, the exact line of code and why it is risky. List every finding before you change anything.
Then fix it: replace reflected origins and regexes with an exact allowlist of https origins read from an environment variable such as ALLOWED_ORIGINS (comma separated). Never allow "null". Add Vary: Origin whenever the header changes by request. Only use "*" on endpoints that return public data with no cookies. If the frontend and API share one origin, remove the CORS config.
Also confirm that every route returning private data checks the session and ownership on the server, because CORS does not do that.
Explain each change, and add a test that sends Origin: https://evil.example and expects no Access-Control-Allow-Origin header.Further reading
- MDN: Cross-Origin Resource Sharing (CORS)
- Express cors middleware docs, including Common Misconceptions
- OWASP WSTG: Testing Cross Origin Resource Sharing
- PortSwigger Web Security Academy: CORS
- CodeQL: CORS misconfiguration for credentials transfer
FAQ
What is a CORS misconfiguration?
It's a CORS policy that lets websites read your API's responses when they should not be allowed to. Common causes are reflecting the Origin header, allowing null, and loose regex allowlists, especially with Access-Control-Allow-Credentials: true.
Is Access-Control-Allow-Origin: * a vulnerability?
Not by itself. It's fine for public data that needs no cookies, and browsers refuse it when credentials are involved. It becomes a problem when the response is private, or when the app sits on a private network and relies on that network for protection, because a visitor's browser can carry an outside page's request in.
Does CORS protect my API from curl or Postman?
No. CORS is enforced by browsers, and curl, Postman and other servers ignore it. Your server still receives every request, so protect private endpoints with authentication and authorization.
How do I allow multiple origins in Express cors?
Pass an array of exact origin strings, such as ["https://myapp.com", "https://www.myapp.com"], or a function that checks the origin against your list. Avoid origin: true, which reflects any origin, and avoid regexes, which are easy to get wrong. Then run the curl loop from the test section to confirm the headers.
Why do I get a CORS error even though my server responded?
The browser blocked your script from reading the response because the CORS headers didn't allow your origin. JavaScript only sees that an error happened, so open the browser console for details. Fix the headers on the server, and handle the OPTIONS preflight if you send JSON, an Authorization header, other custom headers, or methods other than GET, HEAD or POST.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms