LEAKCADE

CORS misconfiguration: how one header lets any site read your API

Updated 8 min read

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 a reflected origin leaks dataA logged-in user opens evil.example. Their browser calls your API with cookies and an Origin of evil.example. Your API reflects that origin and allows credentials, so the browser lets the attacker's page read the response.ATTACK PATHVisits evil.exampleGET /api/me, Origin: evilAllow-Origin + credentialsBrowser lets page read itPage reads the responseEVILevil.exampleattackerUSERVictimlogged inAPIYour APIorigin: trueATTACK PATHVisitsevil.exampleGET /api/me,Origin: evilAllow-Origin +credentialsBrowser lets page read itPage reads theresponseEVILevil.exampleattackerUSERVictimlogged inAPIYour APIorigin: true
The server's own headers give the attacker's page permission to read the response.

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 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.

SettingWhat the browser doesVerdict
origin * with no credentialsAny site reads the responsePublic data only
origin * with credentialsBrowser rejects the responseBroken, use a list
origin: true with credentialsAny site reads private dataNever
Exact allowlist with credentialsOnly listed sites readYes
Suffix regex or null allowedLookalike pages read itNever

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.

BEFOREExpress: reflects any origin
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 }))
AFTERExpress: exact allowlist
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,
  })
)
In TypeScript, also install @types/cors. Origins not on the list get no Access-Control-Allow-Origin header, so the browser blocks their reads.

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.

BEFORENext.js: echoes the Origin header
// 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",
    },
  })
}
AFTERNext.js: allowlist plus Vary: Origin
// 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 })
}
Cross-origin calls that use methods other than GET, HEAD or POST, a Content-Type such as application/json, or headers such as Authorization trigger a preflight, a check the browser sends first as an OPTIONS request. Export an OPTIONS handler that runs the same origin check and adds Access-Control-Allow-Methods and Access-Control-Allow-Headers.

How to fix a CORS misconfiguration, step by step

  1. 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.

  2. Write the origins down

    List each origin exactly: scheme, host and port, such as https://myapp.com. No wildcards, no regexes, no null. Keep the list in an env variable like ALLOWED_ORIGINS. Add http://localhost:3000 only in your local .env, never in production.

  3. Compare, then echo

    Send Access-Control-Allow-Origin only when the request's Origin is an exact match for an entry. For any other origin, send no CORS headers at all.

  4. 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 show Vary: Origin on their single-origin and dynamic examples.

  5. 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.

  6. 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)"
done

A 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.

FIX PROMPT
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

  1. MDN: Cross-Origin Resource Sharing (CORS)
  2. Express cors middleware docs, including Common Misconceptions
  3. OWASP WSTG: Testing Cross Origin Resource Sharing
  4. PortSwigger Web Security Academy: CORS
  5. 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 repo

Leakcade 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

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.