π CORS Configuration
You built a React app on localhost:5173 and an Express API on localhost:3001. The first fetch between them fails with a red console error about "Access-Control-Allow-Origin," and nothing you change on the frontend fixes it. That error is CORS doing its job β and this lesson teaches you to configure it correctly instead of disabling it.
Week 10 · Monday: Connecting Frontend to Backend · Lecture 1
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain the browser's same-origin policy and why cross-origin requests are blocked by default
- Distinguish a simple request from one that triggers a preflight
OPTIONScheck - Configure the Express
corsmiddleware with a specific origin allowlist instead of a wildcard - Enable cookies safely with
credentials: trueand know why that forbidsorigin: '*' - Read a CORS error in DevTools and map it to the exact header that is missing
- Sidestep CORS in development with a Vite dev-server proxy
Estimated Time: 60 minutes
Practice: Wire an environment-driven origin allowlist into an Express API and prove it from two different origins.
In This Lesson
What CORS Actually Is
CORS (Cross-Origin Resource Sharing) is a browser security mechanism that controls whether JavaScript running on one origin is allowed to read responses from a different origin. An origin is the combination of scheme, host, and port β https://app.example.com is a different origin from https://api.example.com (different host) and from http://app.example.com (different scheme).
Here's the analogy: think of the same-origin policy as building security. If you badge into Company A, you can walk its floors and open its filing cabinets freely. You cannot stroll into Company B down the hall β even in the same building β without a visitor pass. CORS is that visitor-pass system: the API (Company B) decides, via response headers, which outside origins are allowed in and what they may do.
β οΈ CORS is enforced by the browser, not the server
The server always processes the request. CORS only decides whether the browser will hand the response back to your JavaScript. That is why curl and Postman never hit CORS errors β there is no browser policing them β and why you can never fix a CORS error by editing frontend code alone. The fix always lives on the server that owns the resource.
Why it exists
Without the same-origin policy, any site you visited could quietly fire authenticated requests at bank.com using cookies your browser already holds, then read your balance or move money. CORS keeps that door shut by default and opens it only for origins a server explicitly trusts. Legitimate apps still need cross-origin calls all the time β a frontend on app.example.com talking to an API on api.example.com β so CORS is the controlled, opt-in way to allow exactly that.
How CORS Works: The Headers
When the browser makes a cross-origin request, it automatically attaches an Origin header naming the calling page. The server inspects that header and, if it approves, sends back matching Access-Control-* response headers. The browser compares the two; a mismatch means the response is withheld from your code.
The headers that matter
| Header | What it controls |
|---|---|
Access-Control-Allow-Origin | Which single origin may read the response (or * for any) |
Access-Control-Allow-Methods | Which HTTP methods are permitted (GET, POST, PUT, DELETEβ¦) |
Access-Control-Allow-Headers | Which request headers the client may send (e.g. Authorization) |
Access-Control-Allow-Credentials | Whether cookies and auth headers are allowed (true) |
Access-Control-Expose-Headers | Which response headers JS is allowed to read (e.g. X-Total-Count) |
Access-Control-Max-Age | How long the browser may cache a preflight result, in seconds |
Why it matters: every CORS error you will ever debug is really "one of these headers is missing or does not match." Learn the six and the console message stops being cryptic.
Simple vs Preflight Requests
Not every cross-origin request is checked the same way. The browser splits them into two categories, and knowing which you are making explains a mysterious extra OPTIONS request you will see in the Network tab.
Simple requests
A request is "simple" β sent straight to the server with no advance check β when it uses only GET, HEAD, or POST, sets only safe-listed headers, and (for POST) uses a Content-Type of application/x-www-form-urlencoded, multipart/form-data, or text/plain. The moment you add an Authorization header or send JSON, it stops being simple.
// A simple request: GET with no custom headers.
// The browser sends it directly and checks the response headers afterward.
const res = await fetch('https://api.example.com/products');
const data = await res.json();
Preflight requests
For anything more complex β a PUT, a DELETE, a JSON body with Content-Type: application/json, or a custom header β the browser first sends a preflight: an automatic OPTIONS request that asks "may I send the real request?" Only if the server approves does the actual request go out. Think of it as phoning ahead: "I would like to bring equipment into your conference room β is that allowed?"
// This PUT triggers a preflight because of the JSON Content-Type
// and the Authorization header. The browser sends OPTIONS first.
const res = await fetch('https://api.example.com/products/42', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ name: 'Updated widget' })
});
π‘ The 204 you keep seeing
That extra OPTIONS line returning 204 No Content in your Network tab is the preflight succeeding. It is normal. Setting Access-Control-Max-Age lets the browser cache the answer so it stops re-asking on every call.
Configuring CORS in Express
In Express you almost never set these headers by hand. The official cors middleware does it, and it handles preflight OPTIONS automatically once mounted.
The tempting-but-wrong version
const express = require('express');
const cors = require('cors');
const app = express();
// Allows EVERY origin. Fine for a public read-only API,
// dangerous for anything with auth. Do not ship this by default.
app.use(cors());
app.get('/api/products', (req, res) => {
res.json([{ id: 1, name: 'Widget' }]);
});
app.listen(3001, () => console.log('API on http://localhost:3001'));
cors() with no options sends Access-Control-Allow-Origin: *. That is genuinely fine for a public, cookie-free API β but the instant you need credentials or you are serving private data, the wildcard is a liability.
The version you should ship: an allowlist
List the exact origins you trust, and validate the incoming Origin against them with a function. This is the modern default for a real full-stack app.
const express = require('express');
const cors = require('cors');
const app = express();
// Pull the allowlist from an env var (comma-separated) so
// dev, staging, and prod each get their own trusted origins.
const allowlist = (process.env.CORS_ORIGINS || 'http://localhost:5173')
.split(',')
.map(o => o.trim());
const corsOptions = {
origin(origin, callback) {
// Requests with no Origin (curl, same-origin, mobile apps) are allowed.
if (!origin || allowlist.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400 // cache preflight for 24h
};
app.use(cors(corsOptions));
app.get('/api/products', (req, res) => {
res.json([{ id: 1, name: 'Widget' }]);
});
app.listen(3001, () => console.log('API on http://localhost:3001'));
β Why the allowlist wins
An explicit list means a new, untrusted origin is rejected by default rather than allowed by accident. Driving it from process.env.CORS_ORIGINS keeps the trusted set out of your code and lets you tighten it in production without a rebuild β exactly the pattern you'll formalize in the Environment Variables lesson next.
Different rules for different routes
You can even apply a permissive policy to a public endpoint and a strict one to a private endpoint, by passing cors() as route-level middleware:
const publicCors = cors({ origin: '*' });
const privateCors = cors(corsOptions); // the strict allowlist above
// Anyone may read the public catalog.
app.get('/api/public/catalog', publicCors, (req, res) => {
res.json({ items: [] });
});
// Only allowlisted origins, with credentials, may hit the account route.
app.get('/api/account', privateCors, (req, res) => {
res.json({ user: req.user });
});
Credentials & the Wildcard Rule
By default the browser does not attach cookies or HTTP-auth to cross-origin requests. To send them, both sides must opt in β and there is one rule that trips up almost everyone.
Both sides must agree
// SERVER: allow credentials for a specific origin
app.use(cors({
origin: 'http://localhost:5173',
credentials: true
}));
// CLIENT (fetch): opt in with credentials: 'include'
await fetch('https://api.example.com/api/account', {
credentials: 'include'
});
// CLIENT (axios): opt in with withCredentials
import axios from 'axios';
await axios.get('https://api.example.com/api/account', {
withCredentials: true
});
β οΈ The rule everyone forgets
When credentials: true is set, Access-Control-Allow-Origin cannot be *. The browser flatly refuses to send cookies to a wildcard origin. You must echo back a single, specific origin β which is exactly why the allowlist-function pattern above exists: it reflects the one matching origin instead of a wildcard.
The DevTools message for this one is unmistakable: "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'." When you see it, switch from origin: '*' to a specific origin or the validating function.
Reading CORS errors like a pro
| Console says⦠| Fix |
|---|---|
| No 'Access-Control-Allow-Origin' header is present | The origin is not in your allowlist β add it |
| Request header field authorization is not allowed | Add Authorization to allowedHeaders |
| Method PUT is not allowed | Add PUT to methods |
| β¦must not be the wildcard '*' when credentials mode is 'include' | Use a specific origin, not * |
The Dev Proxy Shortcut
During development there is an elegant way to avoid CORS entirely: make the browser think the API is same-origin. Vite's dev server can proxy any path to your backend, so a request to /api/... looks like it goes to localhost:5173 but is quietly forwarded to localhost:3001.
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
// Anything starting with /api is forwarded to the Express server.
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
});
// Now the frontend calls a SAME-ORIGIN path β no CORS involved.
const res = await fetch('/api/products');
const data = await res.json();
π‘ Proxy for dev, allowlist for prod
The proxy only runs in the Vite dev server, so it never reaches production. In prod your frontend is served from a real domain and calls the API directly β that is where the Express allowlist takes over. Use both: the proxy keeps local development frictionless, the allowlist keeps production locked down. See the Vite proxy docs for advanced rewrites.
A heavier alternative is the Backend-for-Frontend (BFF) pattern: a small server that shares the frontend's origin and forwards to internal services server-side, so the browser never makes a cross-origin call. You'll meet it in depth in the next lesson on API integration patterns.
Practice & Quiz
ποΈ Exercise 1: An environment-driven allowlist
Goal: Write a buildCorsOptions function that reads a comma-separated CORS_ORIGINS string and returns a cors options object that allows only those origins (plus origin-less requests) and enables credentials.
function buildCorsOptions(originsString) {
// TODO: split the string into an allowlist, return a cors options
// object whose origin() callback allows listed origins + no-origin,
// rejects everything else, and sets credentials: true.
}
const opts = buildCorsOptions('http://localhost:5173,https://app.example.com');
// opts.origin('https://app.example.com', (e, ok) => ...) should allow
// opts.origin('https://evil.com', (e, ok) => ...) should reject
π‘ Hint
Split on commas and trim() each entry into an array. In the origin callback, allow when !origin or the array includes(origin); otherwise call back with an Error.
β Solution
function buildCorsOptions(originsString) {
const allowlist = originsString.split(',').map(o => o.trim());
return {
origin(origin, callback) {
if (!origin || allowlist.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
};
}
ποΈ Exercise 2: Diagnose the error
Goal: A teammate set app.use(cors({ origin: '*', credentials: true })) and cookies still are not sent. Explain the bug and give the one-line fix.
β Solution
credentials: true is incompatible with origin: '*' β the browser refuses to send cookies to a wildcard origin. Replace the wildcard with a specific origin (or a validating function):
app.use(cors({ origin: 'http://localhost:5173', credentials: true }));
π― Quick Quiz
Question 1: Where is CORS enforced?
Question 2: Which request triggers a preflight OPTIONS?
Question 3: With credentials: true, what is the one thing Access-Control-Allow-Origin must NOT be?
Best Practices & Pitfalls
β Do
- Maintain an explicit origin allowlist and drive it from an environment variable
- Restrict
methodsandallowedHeadersto what your API actually uses - Use a specific origin whenever
credentials: trueis on - Set a sensible
maxAgeto cache preflights and cut network chatter - Use a Vite dev proxy locally so day-to-day work never hits CORS at all
β Don't
- Ship
origin: '*'on any endpoint that returns private or authenticated data - Combine
origin: '*'withcredentials: trueβ it silently breaks cookies - Try to "fix" a CORS error by editing frontend code; the fix is on the server
- Disable CORS or reach for a browser extension to bypass it β you are hiding a real config gap
β οΈ CORS is not authentication
CORS restricts which browser origins can read your responses. It does nothing to stop a determined attacker using curl or a script. Real protection still comes from authentication, authorization, and rate limiting on the server. Treat CORS as one layer, never the whole wall.
Summary
π Key Takeaways
- CORS is a browser mechanism that gates cross-origin reads; the server still runs the request
- The server grants access through
Access-Control-*response headers - Complex requests get a preflight
OPTIONScheck before the real call - Ship an origin allowlist via the
corsmiddleware, not a wildcard credentials: trueforbidsorigin: '*'β use a specific origin- A Vite dev proxy removes CORS friction in development entirely
π Additional Resources
- MDN β Cross-Origin Resource Sharing (CORS)
- Express β CORS middleware
- MDN β CORS error reference
- Vite β Dev server proxy options
π What's Next?
Now that the browser will let your frontend talk to your API, the next question is how to structure that conversation cleanly: API Integration Patterns β direct calls, a reusable API client wrapper, the Backend-for-Frontend pattern, and when to reach for each.
π CORS demystified!
That red console error is no longer a mystery β it's a checklist. On to wiring the two sides together the right way.