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
Authorizationheader 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.
Copy-paste: Express with cookie auth
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());
Cookie requirements people forget
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
HttpOnlycookie - refresh endpoint uses credentialed CORS
- API calls use
Authorization: Bearer ...
That means you need to support both:
Access-Control-Allow-Headers: AuthorizationAccess-Control-Allow-Credentials: trueon 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
HttpOnlysession 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
HttpOnlycookies - 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}`
}
});
Cookie auth
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.