CORS gets messy the moment authentication enters the picture.

A simple public GET with Access-Control-Allow-Origin: * is easy. GitHub’s API does exactly that for many responses:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning

That works because a browser can fetch public data without credentials. The moment you send cookies or an Authorization header, the rules change.

This is the reference I wish people handed me years ago.

The core rule

CORS is a browser enforcement layer. Your server still receives the request. CORS decides whether browser JavaScript can read the response.

Authentication changes CORS behavior in two common ways:

  • JWT in Authorization header usually triggers a preflight
  • Cookies require credentialed CORS, which forbids * as the allowed origin

That’s the whole game.


JWT vs cookies from a CORS perspective

JWT in Authorization header

Typical request:

GET /api/me HTTP/1.1
Origin: https://app.example.com
Authorization: Bearer eyJhbGciOi...

Because Authorization is not a “simple” request header, the browser usually sends an OPTIONS preflight first.

Your API must answer with something like:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Vary: Origin

Cookies

Typical browser request:

GET /api/me HTTP/1.1
Origin: https://app.example.com
Cookie: session=abc123

If frontend JavaScript sends cookies cross-origin, you need:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

And the frontend must opt in too:

fetch("https://api.example.com/api/me", {
  credentials: "include"
});

You cannot combine:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Browsers reject that. If credentials are involved, you must return the exact origin.


Quick decision table

Scenario Preflight? Needs Allow-Credentials? Can use * origin?
Public API, no auth Usually no No Yes
JWT in Authorization Usually yes No Sometimes, but usually not worth it
Session cookie auth Sometimes Yes No
Cross-site SPA with refresh cookie Yes or sometimes Yes No

My opinion: if you control both frontend and backend, cookies are usually cleaner for browser apps. If you have multiple clients beyond browsers, bearer tokens are often easier operationally.


Copy-paste: Express with JWT in Authorization header

This is the standard SPA-to-API setup.

import express from "express";
import cors from "cors";

const app = express();

const allowedOrigins = [
  "https://app.example.com",
  "http://localhost:5173"
];

app.use(cors({
  origin(origin, callback) {
    // Allow server-to-server and curl requests without Origin
    if (!origin) return callback(null, true);
    if (allowedOrigins.includes(origin)) return callback(null, true);
    return callback(new Error("CORS blocked"));
  },
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
  allowedHeaders: ["Authorization", "Content-Type"],
  exposedHeaders: ["ETag", "Link", "X-RateLimit-Remaining"],
  maxAge: 600
}));

app.use(express.json());

app.get("/api/me", (req, res) => {
  const auth = req.get("Authorization") || "";
  if (!auth.startsWith("Bearer ")) {
    return res.status(401).json({ error: "missing bearer token" });
  }

  res.json({ id: 123, name: "Ada" });
});

app.listen(3000);

Frontend:

const res = await fetch("https://api.example.com/api/me", {
  headers: {
    Authorization: `Bearer ${token}`
  }
});

const data = await res.json();
console.log(data);

What the preflight looks like

Browser sends:

OPTIONS /api/me HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization

Server should answer:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 600
Vary: Origin

If Authorization is missing from Access-Control-Allow-Headers, the browser blocks the real request before it even sends it.


For browser apps, this is the setup I reach for most often.

import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";

const app = express();

const allowedOrigins = [
  "https://app.example.com",
  "http://localhost:5173"
];

app.use(cors({
  origin(origin, callback) {
    if (!origin) return callback(null, true);
    if (allowedOrigins.includes(origin)) return callback(null, true);
    return callback(new Error("CORS blocked"));
  },
  credentials: true,
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
  allowedHeaders: ["Content-Type", "X-CSRF-Token"],
  exposedHeaders: ["ETag"],
  maxAge: 600
}));

app.use(express.json());
app.use(cookieParser());

app.post("/login", (req, res) => {
  res.cookie("session", "abc123", {
    httpOnly: true,
    secure: true,
    sameSite: "None",
    path: "/"
  });

  res.json({ ok: true });
});

app.get("/api/me", (req, res) => {
  if (!req.cookies.session) {
    return res.status(401).json({ error: "not logged in" });
  }

  res.json({ id: 123, name: "Ada" });
});

app.listen(3000);

Frontend:

await fetch("https://api.example.com/login", {
  method: "POST",
  credentials: "include",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ username: "ada", password: "secret" })
});

const res = await fetch("https://api.example.com/api/me", {
  credentials: "include"
});

console.log(await res.json());

For cross-site cookies to work in modern browsers, the cookie usually needs:

Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=None

Miss SameSite=None or Secure, and your login “works” on the network tab but the browser silently refuses to attach the cookie later. I’ve seen teams burn days on this.


JWT and cookies are not mutually exclusive

A common production pattern:

  • short-lived access token in memory
  • refresh token in HttpOnly cookie
  • refresh endpoint uses credentialed CORS
  • API calls use Authorization: Bearer ...

That means you need to support both:

  • Access-Control-Allow-Headers: Authorization
  • Access-Control-Allow-Credentials: true on refresh endpoints
  • explicit origins, not *, where cookies are used

Example split:

app.use("/api", cors({
  origin: "https://app.example.com",
  allowedHeaders: ["Authorization", "Content-Type"]
}));

app.use("/auth/refresh", cors({
  origin: "https://app.example.com",
  credentials: true
}));

I prefer keeping the rules explicit per route group when auth modes differ.


Exposing response headers to browser JavaScript

Without Access-Control-Expose-Headers, frontend code can only read a small safelisted set of response headers.

If your frontend needs pagination, rate-limit, or cache metadata, expose them.

GitHub exposes a long list, including:

access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning

That lets browser apps read values like:

const res = await fetch("https://api.example.com/issues");
console.log(res.headers.get("ETag"));
console.log(res.headers.get("Link"));
console.log(res.headers.get("X-RateLimit-Remaining"));

Server example:

Access-Control-Expose-Headers: ETag, Link, X-RateLimit-Remaining

If the header is present in DevTools but res.headers.get() returns null, this is the first place I look.


Common mistakes

1. Using * with cookies

Broken:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Fix:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

2. Forgetting preflight headers for JWT

Broken because browser sends Authorization:

Access-Control-Allow-Origin: https://app.example.com

Fix:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS

3. Forgetting credentials: "include"

Cookies won’t be sent cross-origin unless fetch opts in:

fetch("https://api.example.com/api/me", {
  credentials: "include"
});

4. Missing Vary: Origin

If your CDN or reverse proxy caches CORS responses, Vary: Origin prevents one origin’s response headers from being served to another.

5. Treating CORS as auth

CORS is not authentication, authorization, or CSRF protection.

If you use cookies, you still need CSRF defenses. A token header like X-CSRF-Token is common. If you want to go deeper on related headers beyond CORS, https://csp-guide.com is a decent reference point.


Which one should you pick?

My practical take:

Pick cookies when:

  • your client is a browser app you control
  • your API is primarily for that app
  • you want HttpOnly session or refresh tokens
  • you care about reducing token exposure to JavaScript

Pick JWT bearer tokens when:

  • you have multiple client types
  • non-browser clients matter
  • you want explicit auth on each request
  • you can handle token lifecycle cleanly

Pick hybrid when:

  • you want short-lived access tokens
  • you want refresh tokens protected by HttpOnly cookies
  • you can tolerate a slightly more complex CORS setup

For browser-only apps, I generally trust secure cookies more than stuffing long-lived JWTs into localStorage. That pattern keeps showing up in incident writeups for a reason.


Minimal checklists

JWT with Authorization header

Server:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Vary: Origin

Client:

fetch(url, {
  headers: {
    Authorization: `Bearer ${token}`
  }
});

Server:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=None

Client:

fetch(url, {
  credentials: "include"
});

If you remember only one thing, remember this: JWTs usually need preflight; cookies need credentials; and credentials mean no wildcard origin. That one rule explains most CORS auth bugs.