Antoine Duno
Founder of ZeriFlow · 10 years fullstack engineering · About the author
Key Takeaways
- Using a wildcard CORS policy is one of the most common security mistakes in Node.js APIs. This guide explains what CORS actually does, why * is dangerous when paired with credentials, and how to configure a secure origin allowlist.
- Includes copy-paste code examples and step-by-step instructions.
- Free automated scan available to verify your implementation.
How to Configure CORS Correctly in Node.js (Stop Using *)
CORS in Node.js is one of those topics where most developers learn just enough to make the error go away — and that usually means Access-Control-Allow-Origin: *. That works in development. In production, it communicates to every browser that your API will respond to requests from any origin on the internet.
This guide explains what CORS actually does at the protocol level, why * is problematic, how to build a secure origin allowlist, and how to handle the subtle cases that break real APIs.
What CORS Is and How It Actually Works
CORS (Cross-Origin Resource Sharing) is a browser security mechanism. Its entire purpose is to allow servers to relax the browser''s Same-Origin Policy (SOP) for specific trusted origins.
The Same-Origin Policy prevents a JavaScript application running on https://app.com from reading responses from https://api.other.com. This protects users from malicious sites silently making authenticated requests to other APIs using the user''s cookies.
CORS does not: - Prevent non-browser clients (curl, Postman, server-to-server requests) from accessing your API - Provide authentication - Encrypt traffic - Validate the identity of the caller
It only tells the browser whether to allow a cross-origin request to proceed and whether to expose the response to JavaScript.
The Preflight Flow
For any request that is not a "simple request" (GET/POST with specific content types), the browser first sends an OPTIONS preflight request to check if the actual request is allowed:
OPTIONS /api/users HTTP/1.1
Host: api.yourdomain.com
Origin: https://app.yourdomain.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, AuthorizationYour server must respond with headers that grant permission:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.yourdomain.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400Then the browser makes the actual request. If the preflight fails, the browser blocks the request and you see the CORS error in the console.
Why Wildcard Is Dangerous
Access-Control-Allow-Origin: * means the browser allows any website on the internet to make requests to your API and read the responses.
Why * breaks with credentials
The browser specification prohibits Access-Control-Allow-Origin: * when Access-Control-Allow-Credentials: true. If you try to use both, the browser blocks the request. This is why you will see this error:
The value of the ''Access-Control-Allow-Origin'' header in the response must not
be the wildcard ''*'' when the request''s credentials mode is ''include''.This is good — the combination would allow any site to make credentialed requests to your API.
The real problem with *
Even without credentials, a wildcard CORS policy allows authenticated single-page applications to be used as a vector. Here is a real attack scenario:
- 1A user is logged into your app at
https://app.yourdomain.com - 2They visit
https://evil.com - 3
evil.comuses JavaScript to make a request to your API with the user''s session cookies - 4With
*, the browser allows the response to be read
If your API relies solely on CORS to restrict access (it should not, but many do in development), a wildcard policy is a complete bypass.
Configuring a Secure Origin Allowlist in Express
Basic allowlist
const express = require("express");
const cors = require("cors");
const app = express();
const allowedOrigins = [
"https://yourdomain.com",
"https://app.yourdomain.com",
"https://admin.yourdomain.com",
];
const corsOptions = {
origin: (origin, callback) => {
// Allow requests with no origin (server-to-server, curl, Postman)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS policy violation: origin ${origin} is not allowed`));
}
},
credentials: true, // Allow cookies and Authorization headers
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
maxAge: 86400, // Cache preflight for 24 hours
};
app.use(cors(corsOptions));
// Handle preflight for all routes
app.options("*", cors(corsOptions));Environment-based origins
// Load allowed origins from environment variables
const getAllowedOrigins = () => {
const origins = [
process.env.FRONTEND_URL,
process.env.APP_URL,
].filter(Boolean); // Remove undefined values
if (process.env.NODE_ENV === "development") {
origins.push("http://localhost:3000");
origins.push("http://localhost:3001");
}
return origins;
};
const corsOptions = {
origin: (origin, callback) => {
const allowed = getAllowedOrigins();
if (!origin || allowed.includes(origin)) {
return callback(null, true);
}
callback(new Error(`Origin ${origin} not allowed by CORS`));
},
credentials: true,
};Handling Credentials Correctly
When your frontend sends cookies or Authorization headers cross-origin, both the server and the client must opt in.
Server side
const corsOptions = {
origin: "https://app.yourdomain.com", // Must be explicit, not *
credentials: true, // Allow credentials
};Client side (fetch)
fetch("https://api.yourdomain.com/user", {
method: "GET",
credentials: "include", // Send cookies cross-origin
headers: {
"Content-Type": "application/json",
},
});Client side (axios)
axios.defaults.withCredentials = true;
// Or per-request:
axios.get("https://api.yourdomain.com/user", {
withCredentials: true,
});If the client sends credentials: "include" but the server does not respond with Access-Control-Allow-Credentials: true, the browser discards the response.
Per-Route CORS Configuration
Not all API routes need the same CORS policy. Public endpoints can use *. Authenticated endpoints need a strict allowlist.
const cors = require("cors");
const publicCors = cors({
origin: "*",
methods: ["GET"],
});
const privateCors = cors({
origin: (origin, callback) => {
const allowed = ["https://app.yourdomain.com"];
if (!origin || allowed.includes(origin)) {
return callback(null, true);
}
callback(new Error("Not allowed"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE"],
});
// Public read-only endpoint
app.get("/api/public/status", publicCors, (req, res) => {
res.json({ status: "ok" });
});
// Private endpoint requiring auth
app.get("/api/user/profile", privateCors, authenticateUser, (req, res) => {
res.json(req.user);
});Common CORS Mistakes
Mistake 1: Applying CORS middleware after route definitions
// Wrong — CORS headers not applied to routes defined before this
app.get("/api/data", handler);
app.use(cors(options)); // Too late
// Correct — apply before all routes
app.use(cors(options));
app.get("/api/data", handler);Mistake 2: Forgetting to handle preflight OPTIONS
The cors() middleware handles preflight automatically when used with app.use(). But if you are only applying CORS to specific routes, you must also handle OPTIONS:
// Correct — handle both the preflight and the actual request
app.options("/api/endpoint", cors(corsOptions));
app.post("/api/endpoint", cors(corsOptions), handler);Mistake 3: Regex origin matching that is too broad
// Dangerous — matches evil-yourdomain.com
origin: (origin, cb) => {
if (/yourdomain\\.com/.test(origin)) cb(null, true);
}
// Safe — anchored regex
origin: (origin, cb) => {
if (/^https:\\/\\/([\\w-]+\\.)?yourdomain\\.com$/.test(origin)) cb(null, true);
}
// Best — use an explicit allowlist, not regexMistake 4: Not validating origin before reflecting it
Reflecting the incoming Origin back without validation is a critical vulnerability. Never do this:
// NEVER do this — reflects any origin, same as *
response.setHeader("Access-Control-Allow-Origin", request.headers.origin);Always validate against an explicit allowlist before reflecting.
Mistake 5: Confusing CORS with authentication
CORS is not an authentication mechanism. A 403 Forbidden from your CORS policy only affects browsers. Any server, script, or tool that is not a browser can ignore CORS entirely. Always implement proper authentication (API keys, JWTs, session tokens) independently of your CORS configuration.
Debugging CORS Errors
"No ''Access-Control-Allow-Origin'' header is present"
Your server is not returning CORS headers at all. Check:
- Is cors() middleware applied globally (app.use(cors(options))) or per-route?
- Is the middleware applied before the route handler?
- Is the server restarted after configuration changes?
"The ''Access-Control-Allow-Origin'' header has a value ''...'' that is not equal to the supplied origin"
Your origin allowlist does not include the requesting origin. Log the incoming origin value and verify it matches exactly (including protocol, no trailing slash):
origin: (origin, callback) => {
console.log("Incoming origin:", origin); // Debug log
// ...
}Credentials + wildcard error
The value of the ''Access-Control-Allow-Origin'' header in the response
must not be the wildcard ''*'' when the request''s credentials mode is ''include''.Change origin: "*" to your specific origin and ensure credentials: true is set.
Preflight response has no CORS headers
You are handling the preflight OPTIONS request somewhere before the CORS middleware (e.g., another middleware that intercepts OPTIONS and returns a 404). Add explicit OPTIONS handling:
app.options("*", cors(corsOptions));CORS for Fastify
const fastify = require("fastify")({ logger: true });
const fastifyCors = require("@fastify/cors");
fastify.register(fastifyCors, {
origin: (origin, cb) => {
const allowed = ["https://app.yourdomain.com"];
if (!origin || allowed.includes(origin)) {
cb(null, true);
return;
}
cb(new Error("Not allowed"), false);
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
});Testing Your CORS Configuration
# Test from an allowed origin
curl -H "Origin: https://app.yourdomain.com" \\
-H "Access-Control-Request-Method: POST" \\
-X OPTIONS \\
https://api.yourdomain.com/endpoint \\
-v 2>&1 | grep -i "access-control"
# Test from a disallowed origin (should return no CORS headers)
curl -H "Origin: https://evil.com" \\
-H "Access-Control-Request-Method: POST" \\
-X OPTIONS \\
https://api.yourdomain.com/endpoint \\
-v 2>&1 | grep -i "access-control"For a full automated check including your deployed CORS headers alongside all other security headers, run a free scan at zeriflow.com/free-scan.
Summary
CORS in Node.js should never use a wildcard origin for authenticated endpoints. The correct pattern is an explicit origin allowlist in the cors() callback, credentials: true for cookie/token-based auth, explicit OPTIONS preflight handling, and environment-based configuration that adds localhost only in development. CORS is a browser-only mechanism and does not replace authentication — it is a layer on top. Configure your allowlist carefully, handle preflight requests explicitly, and test from both allowed and disallowed origins to verify your policy is working as intended.