CORS errors explained

Why the browser blocks your API call with a CORS error, what the preflight request actually is, and the exact one-line fix with the cors package — reproduced with real requests.

You built an API, it works in curl, and then your website's JavaScript calls it and the browser console screams:

Access to fetch at 'http://your-api' from origin 'https://your-site'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header
is present on the requested resource.

Nothing is broken. Your API is fine — curl proves it. CORS is a browser safety rule, and this error means your API simply hasn't said "yes, that other website may call me." This guide explains what's really happening and gives the exact fix.

At a glance
You need An Express API on the Node.js application
Background Designing a small REST API
Plan Any plan
Time Fifteen minutes

What CORS actually is

CORS — Cross-Origin Resource Sharing — is a rule browsers enforce, and only browsers. When JavaScript on https://your-site tries to fetch from http://your-api (a different origin — different domain, or even a different port), the browser refuses to hand the response back to your code unless the API explicitly allows that origin with a response header.

Two things follow from that:

  • It's the browser blocking your code, not the server rejecting the request. Your server may have happily returned 200; the browser just won't let the page read it.
  • curl, Postman, your bot's backend, and server-to-server calls don't do CORS at all. That's why the API "works everywhere except the browser."

An origin is the scheme + host + port together. https://site.com, http://site.com, and https://site.com:8080 are three different origins — a mismatch in any of them triggers CORS.

The preflight — the request you didn't send

For anything beyond a simple GET (a POST with a JSON body, a custom header, a DELETE), the browser sends a preflight first: an automatic OPTIONS request that asks "am I allowed to do this?" Your API must answer with the right Access-Control-* headers, or the real request never leaves the browser.

You can watch the difference with curl by imitating what the browser sends. Against a plain Express API with no CORS setup, the preflight comes back naked:

curl -i -X OPTIONS http://YOUR_API/api/data \
  -H "Origin: https://your-site" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type"
HTTP/1.1 200 OK

No Access-Control-* headers in the response — so a real browser stops right there and logs the error. That empty answer is the bug.

The fix: the cors package

The cors middleware adds those headers for you. Install it from the Packages page (search cors, install, restart), then add one line:

const express = require('express');
const cors = require('cors');
const app = express();

// Allow exactly your site. This one line answers preflights too.
app.use(cors({ origin: 'https://your-site' }));

app.use(express.json());
app.get('/api/data', (req, res) => res.json({ message: 'hello' }));
app.post('/api/data', (req, res) => res.json({ ok: true }));

const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));

Run the exact same preflight now and the answer changes completely:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://your-site
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Headers: content-type

The browser sees Access-Control-Allow-Origin matches its origin, the method is allowed, and lets the real request through. A normal GET now carries Access-Control-Allow-Origin: https://your-site too. That's the whole fix.

🎯 Good to know: app.use(cors(...)) must come before your routes, so the middleware runs first and handles the automatic OPTIONS preflight. Put it near the top, right after you create app.

Choosing what to allow

cors() with no options allows every origin (Access-Control-Allow-Origin: *). That's fine for a genuinely public, read-only API — and wrong for anything private, because it invites every website to call yours.

You want Use
A public, read-only API app.use(cors()) — allow all
Only your own site app.use(cors({ origin: 'https://your-site' }))
A few known sites origin: ['https://a.com', 'https://b.com']
Cookies/sessions to cross origins cors({ origin: 'https://your-site', credentials: true }) and fetch(url, { credentials: 'include' }) on the client

⚠️ Heads up: You cannot combine credentials: true with the * wildcard — the browser forbids it. Credentialed requests require naming the exact origin. This is the usual wall when a logged-in session needs to work across domains.

The simplest fix of all: same origin

CORS only exists because the site and the API are on different origins. Serve both from the same Falix server and the problem disappears — no cors package, no preflights. Express is happy to serve your web page and your API routes from one app on your one SERVER_PORT, exactly as Build an Express website + API shows. If a single build serves your frontend and backend together, you never meet CORS at all.

For the standard behind all of this, MDN's CORS reference is the canonical explainer, and the middleware's own docs are at github.com/expressjs/cors.

Troubleshooting

  • Still blocked after adding cors — the allowed origin string must match the browser's origin exactly, including http vs https and any port. Copy it verbatim from the error message.
  • GET works, POST is blocked — that's the preflight. Make sure app.use(cors(...)) runs before your routes so OPTIONS gets answered.
  • Cookies don't cross — you need credentials: true on the server and a named (non-wildcard) origin and credentials: 'include' on the client fetch.
  • Works locally, breaks after adding a domain — once you put the API behind the Reverse Proxy for HTTPS, its origin becomes https://.... Update the allowed origin to match (Domains and HTTPS).

Next steps

Was this guide helpful?