← All news
security5 min read

Why Access-Control-Allow-Origin: * is riskier than it looks

By Seaworthy · July 24, 2026

A padlock stamped "hardened" sitting on a backlit mechanical keyboard, lit in red and green

Photo by FlyD on Unsplash

The two-second fix nobody revisits

You're building an API. Your frontend is on localhost:3000, the API is on localhost:8000, and the browser throws a CORS error the moment you try to call it. You paste app.use(cors()) in, or add Access-Control-Allow-Origin: * to the response headers, and the error goes away. You move on. Six weeks later that same line is running in production, still wide open, and nobody remembers putting it there.

I've seen this in enough repos to stop being surprised by it. It's not laziness exactly, it's that the fix worked, the error disappeared, and CORS headers don't show up in a code review the way a missing auth check does. They look like plumbing. They're not.

What the header actually controls

Here's the bit that trips people up: Access-Control-Allow-Origin doesn't control who can send a request to your API. It controls who can read the response in a browser. Those are very different things, and conflating them is where most of the risky configs come from.

A request from evil-site.com to your API goes out over the wire regardless of your CORS headers. Your server processes it, runs your handlers, hits your database, and returns a response. CORS is a browser-side restriction, enforced by the browser that's making the fetch call, on whether the JavaScript that initiated the request is allowed to see what came back. curl doesn't care. Postman doesn't care. A server-side script making the same request doesn't care. CORS is a rule between browsers and browsers, full stop.

So when a developer sets Access-Control-Allow-Origin: *, the intent is usually "let anyone call this," and the actual effect is "let any website's JavaScript read the response, in a browser, on behalf of whoever's currently looking at that page." If your API doesn't handle anything sensitive, that's fine, that's what public APIs are for. If it does, the wildcard just handed every other website on the internet a read pipe into your users' data, mediated only by whatever auth check runs before the response goes out.

Cookies: the trap that's actually a safety net (until you break it)

If your API authenticates with a session cookie, there's a detail worth knowing: browsers refuse to combine Access-Control-Allow-Origin: * with credentialed requests. If your frontend does fetch(url, { credentials: 'include' }) and the server literally answers with *, the browser blocks the response outright. That's a spec-level guard, and it accidentally protects a lot of naively wildcarded cookie-based APIs.

The trouble starts when developers hit that block and "fix" it by switching from a literal wildcard to reflecting whatever Origin header came in, while leaving credentials on:

import cors from 'cors';

app.use(cors({
  origin: true,       // reflects the request's Origin header back verbatim
  credentials: true,  // and now cookies ride along too
}));

That combination passes the browser's check because it's technically not a wildcard, Access-Control-Allow-Origin echoes back the exact origin that asked. But functionally it's the same as allowing everyone, because it allows every origin, one request at a time. Now a page on evil-site.com can fetch your API with credentials: 'include', the victim's session cookie goes along if SameSite allows it, and the attacker's JavaScript gets to read the response. That's a session-riding read primitive, and it's strictly worse than classic CSRF because the attacker isn't just triggering a state change blind, they're reading the data back.

Bearer tokens don't save you either

If your API uses Authorization: Bearer <token> instead of cookies, wildcard CORS looks safer, and mostly it is: a random site's JavaScript can't attach a token it doesn't have. But "made permissive to unblock local dev" configs rarely stay scoped to the endpoint that needed it. The same middleware usually gets applied to every route, including the ones that do sit behind cookies, or the admin panel that was added later and quietly inherited the same CORS setup. Once wildcard access is wired into shared middleware, it survives every refactor that doesn't specifically go looking for it.

Public API or local-dev shortcut? Check which one you actually have

A genuinely public API, weather data, a package registry, anything meant to be called from any origin by design, should use a wildcard. That's the correct, intended use of the header. The problem is when a private, authenticated API ends up with the same header because someone needed to unblock a fetch call at 11pm and never came back to scope it down. Those two situations produce identical response headers and completely different risk profiles, and nothing in the code flags which one you're looking at.

The fix is an explicit allowlist, not a toggle:

const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];

app.use(cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) {
      return callback(null, true);
    }
    callback(new Error('Not allowed by CORS'));
  },
  credentials: true,
}));

Add http://localhost:3000 to the allowlist for local dev, keep it out of the production config, and the two environments stop sharing a security posture they were never supposed to share.

Seaworthy's static analysis flags this exact pattern: a Access-Control-Allow-Origin: * (or an origin-reflecting equivalent) on a route that also reads cookies or auth tokens gets called out as overly permissive CORS, rather than waiting for it to show up in a pentest report.

This article was generated by AI and summarises publicly available sources.

From the makers of Seaworthy

Seaworthy scans your repo for the issues covered in articles like this one.

Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.

$ npx seaworthycode