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.
- Copy
templates/gate.jsto your shared library directory (api/_lib/gate.jsfor Vercel functions, orlib/gate.tsfor Next.js). Edit the list at the top:
const ALLOWED_ORIGINS = ['https://YOUR_DOMAIN', 'https://www.YOUR_DOMAIN', 'https://STAGING_DOMAIN'];
- Make the gate the first statement in every handler, before the body is read or the database is touched. The gate returns
nullafter 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.
Remove every other place that sets a CORS header by hand: a framework config, a
vercel.jsonheaders block, or ares.setHeader('Access-Control-Allow-Origin', '*')line in a handler. Two paths to the same decision are two answers waiting to disagree.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: *.
Grade your own URL
Every grade lists this check with its evidence for your site. Two free grades a day.