Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
Node.jsDevelopmentJavaScriptTutorial

How to enable CORS in Node.js: Express 5 and plain node:http

Fix browser CORS errors in Node.js properly: an Express 5 allowlist with the cors package, a plain node:http version, preflight, credentials and the common console errors.

Cloudsoft Zone

Updated 9 min read

Sequence diagram of a browser on app.example.com sending an OPTIONS preflight to a Node.js API, receiving 204 with CORS headers, then sending the real POST
Image: Cloudsoft Zone
In this article

A browser shows a CORS error when a page on one origin calls your Node.js API on another origin and the API's response does not grant that origin access. The fix is to send the right Access-Control-* headers from the API, for the right origins, on both the preflight OPTIONS request and the real request. This guide shows how to do that with Express 5 and the cors package, how to do it with plain node:http, and how to read each browser error so you fix the actual cause instead of switching CORS off.

Updated for Express 5.3 and Node.js 24 LTS. Tested with Node.js 24.19.0, Express 5.3.0, cors 2.8.6 and headless Chromium (Playwright 1.63). Every request and error message below was produced against these servers.

What CORS actually protects

An origin is the scheme, host and port together: http://localhost:5173, http://127.0.0.1:5173 and https://localhost:5173 are three different origins. By default a browser lets a script send a cross-origin request but refuses to let the script read the response. Cross-Origin Resource Sharing (CORS), defined in the WHATWG Fetch standard, is how a server relaxes that rule for origins it trusts.

Two consequences matter for API developers:

  • CORS is enforced by browsers only. curl, Postman, mobile apps and other servers ignore it. It is not authentication and it does not hide your API.
  • A blocked response may still have run on the server. For a "simple" request, such as a POST with Content-Type: text/plain, the browser sends the request first and only blocks the page from reading the answer. Your handler has already executed.

Simple requests and preflight

According to MDN's CORS guide, a request is "simple" when it uses GET, HEAD or POST, sets only safelisted headers, and its Content-Type (if any) is application/x-www-form-urlencoded, multipart/form-data or text/plain. Anything else, including a JSON body (Content-Type: application/json), an Authorization header, or PUT, PATCH and DELETE, triggers a preflight: the browser first sends an OPTIONS request asking which method and headers are allowed, and sends the real request only if the answer permits it.

For a request that carries cookies or HTTP authentication (fetch(url, { credentials: 'include' })), the server must also return Access-Control-Allow-Credentials: true, and it must name the exact origin: the wildcard * is not allowed in Access-Control-Allow-Origin, -Allow-Headers, -Allow-Methods or -Expose-Headers for credentialed requests.

Enable CORS in Express 5 with an allowlist

The cors middleware is maintained by the Express project and handles both preflight and normal responses. Install it next to Express:

Shell
npm install express@5 cors

The old one-liner, app.use(cors()), sends Access-Control-Allow-Origin: * to everyone. That is fine for a genuinely public, read-only API with no cookies. For anything with logins, use an allowlist read from configuration. This complete server is an ES module (express-app.mjs):

JavaScript
import express from 'express';
import cors from 'cors';

const app = express();
const allowedOrigins = new Set(
  (process.env.CORS_ORIGINS ?? 'http://localhost:5173,https://app.example.com').split(','),
);

const corsOptions = {
  origin(origin, callback) {
    // Requests without an Origin header (curl, server-to-server, same-origin GET) are not CORS requests.
    if (!origin || allowedOrigins.has(origin)) return callback(null, true);
    return callback(null, false); // no CORS headers: the browser blocks the response
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: ['X-Request-Id'],
  maxAge: 600,
};

app.use((req, res, next) => {
  res.vary('Origin'); // every response depends on Origin, allowed or not
  const origin = req.get('Origin');
  if (origin && !allowedOrigins.has(origin)) {
    return res.status(403).json({ error: 'Origin not allowed' });
  }
  next();
});
app.use(cors(corsOptions));
app.use(express.json());

app.get('/api/projects', (req, res) => {
  res.set('X-Request-Id', crypto.randomUUID());
  res.json({ data: [{ id: 1, name: 'Website rebuild' }] });
});

app.post('/api/projects', (req, res) => {
  res.status(201).json({ data: { id: 2, ...req.body } });
});

app.listen(process.env.PORT ?? 4711, () => console.log('listening'));

What each part does:

  • The first middleware adds Vary: Origin to every response and answers 403 to browsers on other origins. Without it, a disallowed origin simply gets a response without CORS headers, and, as we saw in testing, a text/plain POST from that origin still reached the handler and returned 201 Created. Same-origin pages also send an Origin header on POST, so add the API's own origin to the list if it serves pages.
  • origin callback: returns true for allowed origins, so cors reflects that one origin back. Requests without Origin (server-to-server calls, curl, health checks) pass through untouched.
  • credentials: true adds Access-Control-Allow-Credentials: true so cookies and Authorization work.
  • exposedHeaders lets browser code read response headers that are not safelisted, such as X-Request-Id.
  • maxAge: 600 lets the browser cache the preflight answer for ten minutes. Browsers cap it: MDN lists 7,200 seconds for Chromium and 86,400 seconds for Firefox, and the default without the header is 5 seconds.

Register cors() before your routes, body parsers, authentication and 404 handler. Headers set by cors stay on the response even when a later handler throws, so error responses remain readable by the page.

Testing the preflight with curl

You can reproduce the browser's preflight from a terminal by sending the same headers:

Shell
curl -i -X OPTIONS http://127.0.0.1:4711/api/projects \
  -H 'Origin: http://localhost:5173' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type,authorization'

The response we got from the server above:

HTTP
HTTP/1.1 204 No Content
Vary: Origin
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Max-Age: 600
Access-Control-Expose-Headers: X-Request-Id

The same request with Origin: https://evil.example returns 403 with only Vary: Origin, and in Chromium the page's fetch rejects with TypeError: Failed to fetch.

The Express 5 wildcard route trap

Many older answers register preflight handling with app.options('*', cors()). Express 5 uses a newer path-to-regexp, and on Express 5.3.0 that line throws at start-up:

Text
PathError: Missing parameter name at index 1: *; visit https://git.new/pathToRegexpError for info

You rarely need a separate OPTIONS route, because app.use(cors(...)) already answers preflights for every path. If you do want one, the Express 5 migration guide explains that wildcards must be named, for example app.options('/{*splat}', cors(corsOptions)).

Enable CORS with plain node:http

Without a framework you set the headers yourself and answer OPTIONS before your routing. This version uses only built-in modules (http-server.mjs):

JavaScript
import { createServer } from 'node:http';

const allowedOrigins = new Set(['http://localhost:5173', 'https://app.example.com']);

function applyCors(req, res) {
  const origin = req.headers.origin;
  res.setHeader('Vary', 'Origin');
  if (!origin || !allowedOrigins.has(origin)) return false;
  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  return true;
}

const server = createServer((req, res) => {
  const allowed = applyCors(req, res);

  if (req.method === 'OPTIONS') {
    if (allowed && req.headers['access-control-request-method']) {
      res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');
      res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
      res.setHeader('Access-Control-Max-Age', '600');
    }
    res.writeHead(204).end();
    return;
  }

  if (req.url === '/api/projects' && req.method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ data: [{ id: 1, name: 'Website rebuild' }] }));
    return;
  }

  res.writeHead(404, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ error: 'Not found' }));
});

server.listen(process.env.PORT ?? 4712);

Three details people miss when writing this by hand: always send Vary: Origin when the answer depends on the origin; only add the Allow-Methods and Allow-Headers headers to real preflights (an OPTIONS request carrying Access-Control-Request-Method); and never echo the request's Origin back without checking it against the list, because reflecting any origin together with Allow-Credentials: true lets every website read your users' data.

If you use NestJS, app.enableCors(options) takes the same options object as the cors package, so the allowlist above carries over unchanged. For framework choice in general, see Express 5 vs Fastify 5.

Common CORS errors and how to fix them

Chromium prints a specific message in the console for each failure. These are the messages we reproduced while testing, with their usual causes:

Table-style diagram mapping five Chromium CORS console messages to their usual causes and fixes, such as wildcard origin with credentials and missing allowed headers
Each CORS console message points to a different misconfiguration on the server.
Console message (Chromium)Usual causeFix
No 'Access-Control-Allow-Origin' header is present on the requested resource.The origin is not in your list (check scheme, port and trailing slash), or a 401, 404 or 500 was sent by code that runs before cors().Add the exact origin string; register cors() first.
Response to preflight request doesn't pass access control checkThe OPTIONS request hit authentication, a redirect (HTTP to HTTPS, missing trailing slash) or a proxy that does not forward it.Let OPTIONS through before auth; call the final URL directly.
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'.app.use(cors()) with credentials: 'include' on the client.Use an allowlist and credentials: true.
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.The header is missing from allowedHeaders.Add it, spelled as the client sends it (matching is case-insensitive).

Other mistakes we see often:

  • Trailing slashes in the allowlist. The Origin header never has a path, so https://app.example.com/ never matches.
  • Allowing null. Sandboxed iframes and file:// pages send Origin: null. Any attacker can produce that value, so never put it on the list.
  • Loose pattern matching. A regular expression like /example\.com$/ also matches https://evil-example.com. Compare exact strings, or anchor the pattern fully (/^https:\/\/([a-z0-9-]+\.)?example\.com$/).
  • CORS headers added twice. If both nginx and Express add Access-Control-Allow-Origin, the browser sees two values and rejects the response. Set CORS in one layer only.
  • Treating CORS as CSRF protection. Cookie-based APIs still need SameSite cookies and, for state-changing routes, a CSRF token or a strict Origin check like the one above.

If a CORS problem turns out to be a symptom of an API whose authentication, proxies and environments have drifted apart, our API development and integration team can review the whole request path rather than patching headers.

Production considerations

  • Keep origins in configuration, one list per environment, and exclude localhost from production.
  • Prefer one origin when you can. Serving the front end and the API from the same origin, for example by routing /api through your reverse proxy, removes CORS and preflight latency altogether.
  • Check your CDN cache key. If responses are cached, Vary: Origin must reach the cache, or one origin's headers will be served to another.
  • Log rejected origins at a low level; a sudden burst usually means a new front-end deployment URL nobody added.

The Node.js production security checklist covers the headers and limits that sit alongside CORS.

When not to enable CORS

  • Server-to-server integrations and webhooks: browsers are not involved, so CORS headers do nothing.
  • A front end and API that already share an origin: adding CORS only widens access.
  • Internal admin APIs: keep them same-origin rather than allowing extra origins "just in case".

Cloudsoft Zone designs and builds Node.js and NestJS APIs with allowlisted CORS, cookie or token authentication and tested error handling. See our API development and integration service.

Frequently asked questions

  • Why does my API work in Postman but not in the browser?

    CORS is enforced only by browsers. Postman and curl do not check Access-Control-Allow-Origin, so they show the response even when a browser would block the page from reading it. Reproduce the browser by sending an Origin header (and for preflights, Access-Control-Request-Method) with curl.

  • Is app.use(cors()) safe in production?

    Only for a public, read-only API that uses no cookies or user-specific data. It sends Access-Control-Allow-Origin: * to every site. For anything behind a login, use an allowlist of exact origins with credentials: true.

  • Can I use Access-Control-Allow-Origin: * with cookies?

    No. For credentialed requests the browser rejects the wildcard and reports that the header must not be * when the credentials mode is include. Return the single requesting origin after checking it against your list, and add Vary: Origin.

  • Does CORS protect my API from other websites?

    Only partly. It stops other sites' scripts from reading responses, but simple requests are still sent and executed. Protect state-changing routes with authentication, SameSite cookies and CSRF tokens or a strict Origin check.

  • Why does app.options('*', cors()) crash in Express 5?

    Express 5 uses a newer path matcher in which a bare * is invalid; it throws PathError: Missing parameter name. Use a named wildcard such as /{*splat}, or rely on app.use(cors()), which already answers preflights.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Cross-Origin Resource Sharing (CORS) — MDN Web Docs
  2. CORS errors — MDN Web Docs
  3. Access-Control-Max-Age — MDN Web Docs
  4. Fetch Standard: CORS protocol — WHATWG
  5. Express cors middleware — Express
  6. Migrating to Express 5 — Express
  7. Express v5.3.0 release — Express (GitHub), 2026-10-09
  8. HTTP module documentation — Node.js

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.