A CORS error ("No 'Access-Control-Allow-Origin' header is present") means a web page on one origin, such as http://localhost:5173, called your API on another origin, and the browser refused to let the page read the response because the response did not say that origin was allowed. The fix is on the server: send Access-Control-Allow-Origin with the page's origin, and answer the browser's preflight OPTIONS request with the methods and headers you accept. In Express that is a few lines of middleware, or app.use(cors()) from the cors package.
The response is the product list, and its Headers tab shows access-control-allow-origin: https://app.example.com: a page on that origin may read it. Now send OPTIONS /api/products/1 with the headers Origin: https://app.example.com and Access-Control-Request-Method: PUT. That is the preflight a browser sends before a PUT, and the answer is 204 with the allowed methods and headers and no body.
What the browser checks
Same-origin pages need nothing. For a cross-origin request the browser reads these response headers:
| Header | Meaning |
|---|---|
Access-Control-Allow-Origin | The one origin allowed to read the response, or * for any |
Access-Control-Allow-Methods | Methods allowed in the real request (preflight only) |
Access-Control-Allow-Headers | Request headers the page may send, such as Authorization (preflight only) |
Access-Control-Allow-Credentials | true if the page may send cookies and read the answer |
Access-Control-Max-Age | Seconds the browser may cache the preflight answer |
Vary: Origin | Tells caches the answer depends on the Origin request header |
A simple GET or form-style POST goes straight to the server, and the browser checks Access-Control-Allow-Origin on the answer. The server still runs the request; CORS only decides whether the page sees the result. A request with a JSON body, an Authorization header, or a method like PUT or DELETE is preceded by a preflight OPTIONS request, and the real request is sent only if the preflight answer allows it. That is why a CORS middleware must answer OPTIONS before any route or auth check runs.
Allow several origins, with credentials
Access-Control-Allow-Origin holds one origin or *, never a list. To allow several, compare the request's Origin header with an allow list and echo it back when it matches:
The first request has no Origin header, like a call from curl or a same-origin page, so it gets {"origin":null} and no CORS headers. Send GET /api/me with the header Origin: http://localhost:5173 and the Headers tab shows that origin echoed back plus access-control-allow-credentials: true. Change the header to Origin: https://evil.example and both CORS headers disappear: the server still answers, but a browser would block the page from reading it. Access-Control-Allow-Credentials: true lets the page send cookies (with fetch(url, { credentials: 'include' })), and browsers reject it together with *, so credentials always need the exact origin.
The cors package
The cors package does the same work with options. Install it with npm install cors:
const cors = require('cors');
// Any origin, no cookies: fine for a public read-only API.
app.use(cors());
// Only your frontend, with cookies.
app.use(cors({
origin: ['https://app.example.com', 'http://localhost:5173'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));
// One route only.
app.get('/api/public', cors(), handler);
Called with no options, cors() sends Access-Control-Allow-Origin: * and answers every preflight. Use * only for public data that needs no login.
Common mistakes
Adding CORS after the routes or after an auth middleware. The preflight OPTIONS request carries no Authorization header, so an auth check placed first answers it with 401 and the browser reports a CORS error. Register the CORS middleware first.
Setting * with credentials. Browsers refuse the response. Echo the specific origin instead.
Trying to fix CORS in the frontend. Request options such as mode: 'no-cors' do not lift the restriction; they give you an opaque response you cannot read. Only the server's headers fix it.
For how app.use() and the order of middleware work, see Express middleware.
Frequently Asked Questions
Why does my API work in Postman or curl but not in the browser?
CORS is enforced only by browsers. Postman, curl and other servers ignore the CORS headers, so they read the response either way. The browser refuses to hand the response to page scripts from another origin unless the server allows that origin.
Is CORS a security feature for my server?
It protects users, not your server. It stops a malicious page from reading responses from your API with the user's cookies. Anyone can still call your API from a script or a server, so authentication and authorization are still your job.
What counts as a different origin?
An origin is scheme, host and port together. http://localhost:5173 and http://localhost:3000 are different origins because the ports differ, and https://example.com and https://api.example.com differ by host.
Can I avoid CORS in development?
Yes, by making the request same-origin: configure your frontend dev server (Vite's server.proxy, for example) to forward /api to the Express server. The browser then talks to one origin and no CORS headers are needed.