CORS and authentication: why your API calls fail and how to fix them correctly

CORS errors are the most common complaint from developers integrating with APIs for the first time. The error message in the browser console — "No 'Access-Control-Allow-Origin' header is present" — tells you almost nothing about what's actually wrong. The actual problem depends on whether you're using token-based auth, cookie-based auth, preflight requests, or some combination, and each case requires a different fix. Adding a wildcard Access-Control-Allow-Origin: * header is often the first thing developers try, and it silently breaks cookie authentication in ways that are hard to diagnose later.

Why CORS exists

Browsers implement the Same-Origin Policy: a script from https://app.example.com cannot read the response from a request to https://api.other.com unless the other origin explicitly allows it. CORS (Cross-Origin Resource Sharing) is the mechanism that lets the other origin opt in. Without CORS, a malicious site could make authenticated requests to your bank on behalf of the logged-in user and read the response.

The key insight: CORS is a browser policy enforced by the browser, not the server. The server still receives and processes cross-origin requests — the browser simply refuses to give the response to the requesting JavaScript code if the CORS headers aren't correct. This means CORS provides zero protection against server-side forgery or non-browser clients. It only protects users from malicious websites running in their browser.

Simple vs preflighted requests

"Simple" requests (GET, POST with specific content types, no custom headers) are sent directly. "Preflighted" requests trigger an additional OPTIONS request first, which the browser uses to ask the server if the real request is allowed.

Authentication-bearing requests are almost always preflighted. An API request with Authorization: Bearer {token} has a custom header, which triggers a preflight. A request with Content-Type: application/json also triggers a preflight. Your API must handle OPTIONS requests and return the appropriate CORS headers:

// Express: proper CORS middleware for an authenticated API
import cors from 'cors';

const ALLOWED_ORIGINS = [
  'https://app.example.com',
  'https://staging.example.com',
  ...(process.env.NODE_ENV === 'development' ? ['http://localhost:3000'] : []),
];

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (mobile apps, curl, Postman)
    if (!origin) return callback(null, true);

    if (ALLOWED_ORIGINS.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} not allowed`));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  exposedHeaders: ['X-Request-ID', 'X-RateLimit-Remaining'],
  credentials: true,       // required for cookie-based auth
  maxAge: 86400,           // preflight cache: 24 hours
}));

The wildcard problem

Access-Control-Allow-Origin: * is the nuclear option. It allows any origin to read your API's responses. For a public API with no authentication, this is fine. For any API that uses cookies or that could return sensitive data, it's wrong for two reasons:

  1. Browsers reject credentials with wildcard origins. If your response has credentials: true (required for cookies) and Access-Control-Allow-Origin: *, browsers will reject the response entirely. The combination is explicitly disallowed by the CORS spec.
  2. Any malicious site can read your API responses. If a user is logged in to your application and visits a malicious site, that site can make requests to your API bearing the user's cookies and read the responses.

The fix is always to reflect the specific origin or to maintain an allowlist. Never use wildcard with credentials.

Cookie-based auth: credentials mode and SameSite

If your application uses HttpOnly cookies for authentication, the browser will not send those cookies on cross-origin requests unless two things are true: the client must request credentials: 'include', and the server must respond with Access-Control-Allow-Credentials: true and a specific (non-wildcard) Access-Control-Allow-Origin.

// Frontend: making an authenticated cross-origin request with cookies
async function fetchUserProfile() {
  const response = await fetch('https://api.example.com/user/profile', {
    method: 'GET',
    credentials: 'include',  // send cookies cross-origin
    headers: {
      'Content-Type': 'application/json',
    },
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

The SameSite cookie attribute also interacts with CORS. A cookie with SameSite=Strict will never be sent on cross-origin requests, regardless of CORS configuration — the browser drops it before the request is made. Use SameSite=None; Secure for cookies that must be sent cross-origin (like a refresh token cookie on a separate auth subdomain). SameSite=Lax is the default in modern browsers and allows cookies on top-level navigations but not on subresource requests from cross-origin pages.

// Setting a cross-origin-compatible cookie
res.cookie('session_id', sessionId, {
  httpOnly: true,
  secure: true,           // required with SameSite=None
  sameSite: 'none',       // allow cross-origin (e.g., app.example.com -> api.example.com)
  domain: '.example.com', // shared across subdomains
  path: '/',
  maxAge: 24 * 60 * 60 * 1000,
});
For most SaaS applications where the frontend and API are on the same apex domain (app.example.com and api.example.com), use SameSite=Lax with domain=.example.com and configure CORS to allow app.example.com with credentials. Reserve SameSite=None for embedded contexts (iframes, widgets served on third-party sites).

Bearer token auth: simpler CORS, different failure mode

If you use Authorization: Bearer {token} instead of cookies, you don't need credentials: 'include' or SameSite=None. The preflight will ask whether the Authorization header is permitted, and your CORS config must include it in allowedHeaders. The token itself is in the header, not in a cookie, so cookie CORS rules don't apply.

The tradeoff is that bearer tokens in localStorage are accessible to JavaScript, making XSS a more direct path to token theft. The CORS problem is simpler; the XSS surface is larger. Token storage strategy is a separate architectural decision from CORS configuration, but they interact.