Skip to main content

πŸ” CORS Configuration

Week 10: Full-Stack Integration β€” course module banner illustration

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 OPTIONS check
  • Configure the Express cors middleware with a specific origin allowlist instead of a wildcard
  • Enable cookies safely with credentials: true and know why that forbids origin: '*'
  • 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.

flowchart LR A["Browser fetch()"] --> B{"Same origin?"} B -->|Yes| C["Response delivered to JS"] B -->|No| D{"CORS headers allow it?"} D -->|Yes| C D -->|No| E["Browser blocks the read"]

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.

Browser sends Origin header, API responds with Access-Control-Allow-Origin Frontend app.example.com :5173 API Server api.example.com :3001 Origin: app.example.com Access-Control-Allow-Origin
The browser announces its origin; the server answers with the headers that grant (or withhold) access.

The headers that matter

HeaderWhat it controls
Access-Control-Allow-OriginWhich single origin may read the response (or * for any)
Access-Control-Allow-MethodsWhich HTTP methods are permitted (GET, POST, PUT, DELETE…)
Access-Control-Allow-HeadersWhich request headers the client may send (e.g. Authorization)
Access-Control-Allow-CredentialsWhether cookies and auth headers are allowed (true)
Access-Control-Expose-HeadersWhich response headers JS is allowed to read (e.g. X-Total-Count)
Access-Control-Max-AgeHow 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?"

sequenceDiagram participant B as Browser participant S as API Server B->>S: OPTIONS preflight asking to send a PUT with Authorization S->>B: 204 with Allow-Methods and Allow-Headers B->>S: The real PUT request with the JSON body S->>B: 200 with the updated resource
// 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 presentThe origin is not in your allowlist β€” add it
Request header field authorization is not allowedAdd Authorization to allowedHeaders
Method PUT is not allowedAdd 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 methods and allowedHeaders to what your API actually uses
  • Use a specific origin whenever credentials: true is on
  • Set a sensible maxAge to 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: '*' with credentials: 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 OPTIONS check before the real call
  • Ship an origin allowlist via the cors middleware, not a wildcard
  • credentials: true forbids origin: '*' β€” use a specific origin
  • A Vite dev proxy removes CORS friction in development entirely

πŸ“š Additional Resources

πŸš€ 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.