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 automaticOPTIONSpreflight. Put it near the top, right after you createapp.
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: truewith 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 allowedoriginstring must match the browser's origin exactly, includinghttpvshttpsand 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 soOPTIONSgets answered. - Cookies don't cross — you need
credentials: trueon the server and a named (non-wildcard) origin andcredentials: '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
- Designing a small REST API — the API you're unblocking
- Sessions, JWT & passwords — credentialed cross-origin auth
- Build an Express website + API — serve site and API together, no CORS
- Domains and HTTPS — when your origin changes