Securealy
Check · Security · high · passive

No wildcard CORS with credentials

Access-Control-Allow-Origin is not * alongside Allow-Credentials true.

Severity
high
Tier
Passive: runs on any URL
Category
Security
Fix
Free, in full, below
Id
cors-wildcard-credentials

What this means

Passing means your responses never send Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true. Failing means your API tells every browser on the internet that any page may read its responses, and may do so with the visitor's own session attached. A page you have never heard of can then make requests as whoever is signed in and read what comes back.

How we test it

We fetch your home page and one API response the page references, and read the two headers on each. Pass is no Access-Control-Allow-Origin header, or one that names a specific origin. Warn is * on an API response with no credentials flag, because anything gated behind it is readable from a third-party page. Fail is * alongside Access-Control-Allow-Credentials: true.

Why it matters

Any site on the internet could call the API as the signed-in user. CORS is the browser asking your server who may read a response, and * answers "everyone". In the Fix Kit's own audit, many of the 58 client reporting endpoints that answered anonymous requests also sent Access-Control-Allow-Origin: *, so any third-party page could read live ad spend out of a logged-in visitor's browser. A wildcard on a public marketing page is harmless; a wildcard on anything gated is a leak waiting for a visitor.

The fix

The fix is an allow-list of your own origins, set in one place that every endpoint runs through. The Fix Kit's shared gate (templates/gate.js in Module 3) does this: it holds an ALLOWED_ORIGINS list, sets Access-Control-Allow-Origin only when the request's Origin matches one of them, adds Vary: Origin, and never writes *. It also sets Cache-Control: private, no-store on every gated response so a CDN never hands one person's data to the next.

  1. Copy templates/gate.js to your shared library directory (api/_lib/gate.js for Vercel functions, or lib/gate.ts for Next.js). Edit the list at the top:
const ALLOWED_ORIGINS = ['https://YOUR_DOMAIN', 'https://www.YOUR_DOMAIN', 'https://STAGING_DOMAIN'];
  1. Make the gate the first statement in every handler, before the body is read or the database is touched. The gate returns null after it has already sent 401, 403, 405 or 429:
const { gate } = require('../_lib/gate.js');
module.exports = async (req, res) => {
  const who = await gate(req, res, { methods: ['GET'], tenant: 'TENANT_SLUG' });
  if (who === null) return;
  // only now read the body, query the database, serve data
};

Public endpoints use { public: true } so they still get the method, CORS and cache headers without a credential check.

  1. Remove every other place that sets a CORS header by hand: a framework config, a vercel.json headers block, or a res.setHeader('Access-Control-Allow-Origin', '*') line in a handler. Two paths to the same decision are two answers waiting to disagree.

  2. Push, wait for the deploy to show Ready, then check with a single request per path. Never a loop.

curl -s -D - -o /dev/null https://YOUR_DOMAIN/api/YOUR_ENDPOINT | grep -i 'access-control\|cache-control'

Expect no access-control-allow-origin: * and a cache-control that contains private or no-store. The kit's audit script runs this for every gated path you list in surfaces.txt:

bash MODULE-03-lock-it-down/templates/audit.sh cache

Expect cache: 0 failures.

A deeper runbook for this class is in the Fix Kit, Module 03.

Prompt for your AI

Paste this into the coding agent that built the app.

Read every file under api/ (or app/api/) and every config file (vercel.json, next.config.*, middleware.*) and list each place that sets Access-Control-Allow-Origin or Access-Control-Allow-Credentials.
Replace them with one shared gate module that holds an ALLOWED_ORIGINS list of our own origins (https://YOUR_DOMAIN and https://www.YOUR_DOMAIN), sets Access-Control-Allow-Origin only when the request Origin is on that list, adds Vary: Origin, and never writes *.
Make the gate the first statement in every handler, before any body parsing or database call.
Set Cache-Control: private, no-store on every gated response.
Do not change which endpoints are public; show me the list of handlers you touched before committing.
STOP and wait for my yes before pushing to production.
After I confirm the deploy is Ready, run once: curl -s -D - -o /dev/null https://YOUR_DOMAIN/api/YOUR_ENDPOINT | grep -i access-control
Show me the output; expected: no line reading access-control-allow-origin: *.

Next

Grade your own URL

Every grade lists this check with its evidence for your site. Two free grades a day.