Security

CORS misconfiguration in Express and Fastify: how to find it and test for it

Four CORS configurations that leak data, the safe allowlist for Express and Fastify, and curl probes to test yours.

Spot a CORS misconfiguration in one curl. Cover graphic for a guide to finding, fixing and testing CORS misconfiguration in Express and Fastify.

Someone opens a security report against your API: “Access-Control-Allow-Origin reflects arbitrary origins with credentials.” It is usually one line, cors({ origin: true, credentials: true }), added months ago to make a frontend error go away. A CORS misconfiguration like this lets any website a logged-in user visits read that user’s data from your API.

The direct answer: if your API uses cookies or other browser credentials, allow only an exact list of origins, never reflect the request’s Origin, never allow null, and never match origins with an unanchored regex. If your API uses bearer tokens only, you usually do not need credentials in CORS at all. Then test it with three curl commands, shown below.

What CORS actually protects (and what it does not)

Browsers stop a page on https://evil.test from reading responses from https://api.example.com unless the API says otherwise with Access-Control-Allow-Origin. When the response also includes Access-Control-Allow-Credentials: true, the browser will send cookies with the request and let the page read the result.

Three facts about CORS that cause most mistakes:

  1. CORS is enforced by the browser, not your server. curl, scripts and other servers ignore it. It is not authentication or authorization.
  2. CORS controls reading, not sending. For simple requests, the browser sends the request and your handler runs; it just hides the response from the page. A state-changing POST with a form content type still executes.
  3. The dangerous combination is a trusted-looking origin plus credentials. Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true is rejected by browsers, so wildcard plus credentials is broken rather than exploitable. Reflecting the origin is what makes it exploitable.

Four CORS misconfigurations, tested

Here is what the cors middleware for Express returns for four common configurations when probed with different Origin headers (output trimmed to the two CORS headers).

1. Reflecting any origin

js
app.use(cors({ origin: true, credentials: true }));
text
https://evil.test                    ACAO=https://evil.test ACAC=true
https://app.example.com.evil.test    ACAO=https://app.example.com.evil.test ACAC=true
null                                 ACAO=null ACAC=true

origin: true means “echo back whatever Origin was sent”. With credentials, every website on the internet can make authenticated reads as your logged-in users. @fastify/cors with origin: true behaves the same way. This is the one to fix first.

2. An unanchored regex

js
app.use(cors({ origin: /https:\/\/app\.example\.com/, credentials: true }));
text
https://evil.test                    ACAO=-
https://app.example.com.evil.test    ACAO=https://app.example.com.evil.test ACAC=true

The regex matches anywhere in the string, so an attacker registers app.example.com.evil.test. If you need a pattern, anchor it: /^https:\/\/([a-z0-9-]+\.)?example\.com$/. Better still, use a list.

3. Allowing the null origin

js
app.use(cors({ origin: ['https://app.example.com', 'null'], credentials: true }));

Developers add 'null' because local files or a sandboxed iframe sent it during testing. Sandboxed iframes and some redirects send Origin: null, and an attacker can create those, so allowing null is close to allowing everyone.

4. Wildcard with credentials

js
app.use(cors({ origin: '*', credentials: true }));

This returns ACAO=* with ACAC=true. Browsers refuse it, so your frontend breaks, which is often what leads someone to “fix” it with origin: true.

How to configure CORS safely in Express

Use an exact allowlist from configuration, and let requests with no Origin (same-origin, server-to-server, curl) through without CORS headers:

js
import cors from 'cors';

const allowedOrigins = new Set(
  (process.env.CORS_ORIGINS ?? 'https://app.example.com').split(',')
);

app.use(
  cors({
    origin: (origin, callback) => callback(null, !origin || allowedOrigins.has(origin)),
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    maxAge: 600,
  })
);

The cors package adds Vary: Origin when the allowed origin varies, which stops a CDN or proxy from caching one origin’s CORS headers and serving them to another. If you set CORS headers by hand, add Vary: Origin yourself.

Preview deployments on Render, Vercel or Netlify are where allowlists tend to get loosened. Prefer adding preview origins in the preview environment’s config over a wildcard pattern in production.

How to configure CORS safely in Fastify

@fastify/cors accepts the same idea:

js
import Fastify from 'fastify';
import cors from '@fastify/cors';

const app = Fastify();
const allowedOrigins = new Set(process.env.CORS_ORIGINS.split(','));

await app.register(cors, {
  origin: (origin, cb) => cb(null, !origin || allowedOrigins.has(origin)),
  credentials: true,
});

If you add auth in Fastify hooks (see Fastify hooks for logging and auth), remember that preflight OPTIONS requests carry no credentials. Make sure the CORS plugin answers them before your auth hook rejects them with a 401.

How to test your API for CORS misconfiguration

Step 1: probe with curl

bash
API=https://api.example.com/v1/me
for origin in https://evil.test https://app.example.com.evil.test null; do
  echo "== $origin"
  curl -s -o /dev/null -D - -H "Origin: $origin" "$API" \
    | grep -i '^access-control-allow-'
done

Any output line containing the probe origin together with access-control-allow-credentials: true is a finding.

Step 2: probe the preflight

bash
curl -s -o /dev/null -D - -X OPTIONS "$API" \
  -H "Origin: https://evil.test" \
  -H "Access-Control-Request-Method: DELETE" | grep -i '^access-control-'

Step 3: add a regression test

js
import { test } from 'node:test';
import assert from 'node:assert/strict';

test('CORS does not trust arbitrary origins', async () => {
  for (const origin of ['https://evil.test', 'https://app.example.com.evil.test', 'null']) {
    const res = await fetch(`${process.env.API_URL}/v1/me`, { headers: { Origin: origin } });
    assert.notEqual(res.headers.get('access-control-allow-origin'), origin);
  }
});

CORS misconfiguration checklist

  • [ ] No origin: true (or hand-written reflection) together with credentials: true.
  • [ ] Origins are an exact list from config; any regex is anchored with ^ and $.
  • [ ] null is not in the list.
  • [ ] Vary: Origin is present when the allowed origin varies.
  • [ ] Bearer-token-only APIs do not enable credentials.
  • [ ] A test like the one above runs in CI.

A solo developer can do all six in an afternoon. On a team of ten, the useful extra step is making the allowlist a config value owned by one person, so a frontend developer fighting a CORS error opens a PR against config rather than loosening the code. And remember that tight CORS does not replace per-object authorization checks; see BOLA and IDOR.

FAQ

Is Access-Control-Allow-Origin: * insecure?

Not on its own. For a public API that does not use cookies, * is fine, and browsers refuse to combine it with credentials. The risk is reflecting specific origins with credentials enabled.

Does CORS protect my API from attackers?

No. CORS only controls which web pages a browser lets read your responses. Attackers using curl or scripts are unaffected, so authentication, authorization and rate limiting still matter.

Why do I get a CORS error even though my server sends the headers?

Usually the preflight fails: an auth middleware returns 401 to the OPTIONS request, or the requested method or header is not allowed. Check the OPTIONS response in the browser’s network tab first.

Doing this with ApiTraffic

If you would rather not guess which origins actually call your API before tightening CORS, ApiTraffic records every request with its headers from one middleware, so you can see the Origin values hitting each endpoint before you change the allowlist. Credential headers like Authorization and Cookie are masked by default.

The firewall’s access lists can also deny by user agent, path, IP or CIDR, and monitor mode shows what a rule would block before it blocks anything.

Start free and see which origins call your API: apitraffic.io

Sources: MDN: Cross-Origin Resource Sharing (CORS); WHATWG Fetch Standard, CORS protocol; PortSwigger Web Security Academy: CORS; cors middleware README; @fastify/cors README.

Keep reading