AverageDevs

Understanding CORS in Depth and How Browsers Enforce It

A practical deep dive into Cross Origin Resource Sharing (CORS) for mid level web developers - how it actually works, what browsers enforce, and how to design secure APIs without mysterious CORS bugs.

Understanding CORS in Depth and How Browsers Enforce It

If you have ever stared at a red CORS error in DevTools and thought "but my server is responding just fine," this article is for you. We are going to unpack CORS from the browser perspective, treat it as a security protocol rather than a random header puzzle, and look at concrete patterns you can apply across Node, Next.js, and other backend stacks.

Throughout the article I will reference related guides such as REST vs GraphQL for beginners and Next.js SEO best practices for 2025 so you can connect CORS decisions to API design and frontend architecture.

What CORS Actually Is - And What It Is Not

Many developers think of CORS as a server setting that either allows or blocks requests. That model is incomplete. CORS is a browser side security contract that governs when JavaScript in one origin can read responses from another origin.

  • Same origin policy recap: By default, browsers allow scripts to make cross origin requests, but they prevent those scripts from reading responses unless the target origin explicitly opts in via CORS headers. The "origin" is defined as scheme + host + port, so https://app.example.com:443 and https://api.example.com:443 are different origins.
  • Who enforces CORS: Browsers enforce CORS. Your backend happily returns bytes to anyone. It is the browser that decides whether window.fetch or XMLHttpRequest exposes that response to your code.
  • CORS is not authentication: CORS does not know who the user is. It only controls which frontends are allowed to read your responses. You still need proper authentication and authorization.

This distinction matters because it explains why tools like curl or backend to backend traffic happily ignore CORS headers while your React app gets blocked. CORS exists to protect end users and their cookies, not to protect your API from other servers.

If you want a foundations refresh on API and protocol design in general, pair this article with the API versioning and backward compatibility guide.

The CORS Flow From the Browser’s Point of View

Let us walk through what happens when a React app at https://app.example.com calls an API at https://api.example.com.

Step 1 - Classifying the Request

Browsers classify each request as either a simple request or a non simple request.

  • Simple requests:
    • Methods: GET, HEAD, or POST
    • Headers: only a small allow list such as Accept, Accept-Language, Content-Language, and Content-Type with specific values like application/x-www-form-urlencoded, multipart/form-data, or text/plain
  • Non simple requests:
    • Anything else: PUT, PATCH, DELETE, or custom headers like X-Request-Id, Authorization, or JSON Content-Type

Simple requests can be sent directly with CORS checks applied on the response. Non simple requests trigger a preflight.

Step 2 - Optional Preflight Request

For non simple requests, the browser sends an HTTP OPTIONS request first to ask the server which cross origin calls are allowed. That preflight includes:

OPTIONS /v1/todos HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, X-Request-Id

Your API replies with something like:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, X-Request-Id
Access-Control-Max-Age: 600

If that handshake succeeds, the browser proceeds with the actual PUT request. If anything about the preflight response looks wrong or missing, the browser fails the request before your application code ever sees a response.

Step 3 - Enforcing Access on the Response

After the preflight (if needed) the browser sends the real request. When the response comes back, the browser inspects the headers again:

  • If Access-Control-Allow-Origin matches the caller’s origin, and credentials rules are satisfied, the browser exposes the response body and headers to JavaScript.
  • If not, DevTools will show a CORS error. The response may still exist on the network, but your code treats it as a network failure.

This is why "but it works in Postman" is the classic CORS meme. Postman is not a browser and does not play by browser CORS rules.

A High Level CORS Architecture Diagram

Here is a simple mental model you can keep in your head or sketch on a whiteboard when debugging CORS for a new project.

+------------------+         +-------------------+
|  React App JS    |         |   API Server      |
|  (Browser)       |         |  https://api...   |
+---------+--------+         +---------+---------+
          |                            ^
          | fetch("https://api...")    |
          v                            |
+---------+--------+         +---------+---------+
|   Browser CORS   |  HTTP   |  CORS Response   |
|   Enforcement    +-------->+  Headers         |
+------------------+         +------------------+

- JS initiates a request using fetch or XHR.
- Browser CORS layer decides whether to send preflight.
- Browser reads CORS headers from the response.
- Only if the contract matches does the browser expose data to JS.

From an architecture perspective, treat CORS as a gate in front of your JS runtime rather than a flag inside your backend.

If you are interested in broader architecture diagrams, also check the clean architecture fullstack guide which pairs nicely with these CORS concepts.

Core CORS Response Headers and What They Mean

You do not need to memorize every edge case, but you do need to be fluent with the core headers.

Access-Control-Allow-Origin

  • Controls which origins may read the response.
  • Value may be:
    • A specific origin: https://app.example.com
    • * for public data without credentials
  • If the request includes cookies or authorization headers, * is not allowed. You must echo a real origin.

Access-Control-Allow-Methods

  • Advertises which HTTP methods are allowed for cross origin requests.
  • Checked during preflight only.

Access-Control-Allow-Headers

  • Advertises which custom headers the client can send.
  • Also checked during preflight.

Access-Control-Allow-Credentials

  • If true, tells the browser that cookies, Authorization headers, and TLS client certificates are allowed in cross origin requests.
  • Requires a non wildcard Access-Control-Allow-Origin.

Access-Control-Expose-Headers

  • Defines which response headers are visible to JavaScript on cross origin requests.
  • Without this, JS can only see a small allow list like Content-Type and Cache-Control.

Access-Control-Max-Age

  • Tells the browser how long it can cache the result of a preflight request.
  • This reduces preflight noise for high traffic UIs.

Once you can read these headers fluently in DevTools, most CORS bugs become straightforward to debug.

Practical Example 1 - Node/Express Backend with CORS

Let us start with a classic Express API that serves a React SPA from a different origin, such as https://dashboard.example.dev.

// server.ts
import express from "express";
import cors from "cors";

const app = express();
const PORT = 4000;

const ALLOWED_ORIGINS = ["https://dashboard.example.dev", "https://admin.example.dev"];

const corsOptions: cors.CorsOptions = {
  origin(origin, callback) {
    if (!origin) {
      return callback(null, false);
    }

    if (ALLOWED_ORIGINS.includes(origin)) {
      return callback(null, origin);
    }

    return callback(new Error("Origin not allowed by CORS"));
  },
  credentials: true,
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
  allowedHeaders: ["Content-Type", "Authorization", "X-Request-Id"],
  maxAge: 600,
};

app.use(cors(corsOptions));
app.use(express.json());

app.get("/v1/profile", (req, res) => {
  res.json({ id: "user_123", email: "user@example.com" });
});

app.listen(PORT, () => {
  console.log(`API server listening on http://localhost:${PORT}`);
});

Key ideas:

  • Only a trusted list of origins is allowed.
  • credentials: true allows cookies or bearer tokens to flow.
  • Preflight results are cached with maxAge to reduce latency.

On the frontend, you then call this API with fetch and credentials enabled:

// inside a React hook
const fetchProfile = async () => {
  const response = await fetch("https://api.example.dev/v1/profile", {
    method: "GET",
    credentials: "include",
  });

  if (!response.ok) {
    throw new Error(`Request failed with status ${response.status}`);
  }

  return response.json();
};

If CORS is misconfigured, the error you see in React is not the real network error. It is the browser saying "security check failed, you are not allowed to see what came back."

For a broader performance focused look at React network calls and rendering, you can pair this with the optimize React apps for performance guide.

Practical Example 2 - Next.js Route Handlers with CORS

With Next.js App Router, you often host your API and frontend on the same origin, which avoids CORS entirely. But many teams still have separate domains such as https://app.example.com and https://api.example.com.

Here is a minimal CORS helper for a Next.js route handler:

// app/api/cors-example/route.ts
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

const ALLOWED_ORIGINS = ["https://app.example.com"];

const getCorsHeaders = (origin: string | null) => {
  if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
    return {};
  }

  return {
    "Access-Control-Allow-Origin": origin,
    "Access-Control-Allow-Credentials": "true",
    "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
    "Access-Control-Max-Age": "600",
  };
};

export const OPTIONS = async (request: NextRequest) => {
  const origin = request.headers.get("origin");
  const headers = getCorsHeaders(origin);
  return new NextResponse(null, { status: 204, headers });
};

export const GET = async (request: NextRequest) => {
  const origin = request.headers.get("origin");
  const headers = getCorsHeaders(origin);

  const data = { message: "CORS configured correctly", time: new Date().toISOString() };
  return NextResponse.json(data, { headers });
};

Patterns worth copying:

  • Centralize your getCorsHeaders helper.
  • Explicitly implement OPTIONS for preflight.
  • Reflect the request origin only when it is in a trusted list.

If you are deploying Next.js on a VPS or custom infrastructure, combine this with the Next.js deployment on VPS guide so you do not accidentally break CORS at the proxy layer.

Credentials, Cookies, and the Security Model

Credentials are where CORS mistakes get serious. A wrong Access-Control-Allow-Origin combined with Access-Control-Allow-Credentials: true can expose user data to frontends that you do not control.

Remember these rules:

  • When credentials: "include" is used on fetch, the browser:
    • Sends cookies and Authorization headers for that origin.
    • Requires both Access-Control-Allow-Origin and Access-Control-Allow-Credentials: true on the response.
  • Browsers forbid Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true. If you see this combination, the browser will reject it.
  • Backends must never blindly reflect any incoming Origin header while also enabling credentials. Always validate against an allow list.

In other words, you should treat "CORS with credentials" as a cross site extension of your cookie security model. If you would not embed someone else’s frontend on your origin, do not add them to the CORS allow list with credentials enabled.

For a story of security sensitive file flows in general, the secure file upload with virus scanning guide is a great complement to this section.

Common CORS Anti Patterns in Real Projects

After reviewing many production systems, the same patterns show up repeatedly.

1) Using * with Credentials

This typically starts as "just get it working in dev" and ends up in production. As discussed above, browsers will block this combination, so teams start fighting with conflicting recipes from Stack Overflow instead of fixing the root cause.

Better: use an explicit list of origins and enable credentials only when absolutely necessary.

2) Letting Reverse Proxies Strip CORS Headers

Sometimes the app code is perfect, but an NGINX or CDN layer removes or overrides CORS headers. This is especially common when different teams manage infrastructure and application code.

When debugging CORS in a modern stack, always inspect the response at the browser boundary, not only from internal curl calls.

3) Overly Broad Access-Control-Allow-Headers

It is tempting to allow every possible header. While not as dangerous as allowing every origin with credentials, it broadens your attack surface and makes it harder to reason about what clients are actually sending.

Instead, maintain a small, explicit set of allowed headers and periodically review it. If your header list looks like a log of every experiment you ran, that is a smell.

4) Disabling CORS in Dev but Forgetting Prod Parity

In local development, you might run the frontend with a proxy that bypasses CORS, then forget to reproduce that cross origin relationship in staging and production. The first time QA or real users hit the app from the correct domains, everything breaks.

Try to keep your dev topology as close as practical to the production topology. Tools like local DNS and containers help keep those URLs realistic.

Debugging CORS Like a Senior Engineer

When something goes wrong, do not start by randomly tweaking headers. Follow a systematic checklist:

  1. Reproduce with DevTools open: Look at the Network tab, filter on the request, and inspect both the main request and any preflight OPTIONS calls.
  2. Check the Origin header: Confirm that the browser is sending the origin you expect. In some cases, older browsers or special contexts may omit it.
  3. Diff preflight vs main response headers: Ensure that the CORS headers are consistent between OPTIONS and the actual method.
  4. Verify credentials mode: If the frontend uses credentials: "include", confirm that the backend allows credentials and does not use *.
  5. Test with curl or HTTPie only to validate backend behavior: Remember that they bypass CORS, so they are useful for verifying core API behavior, not browser policy.

If you find yourself doing this repeatedly across microservices, consider documenting your standards in an internal "CORS playbook" so new services start from a secure default. Teams that do this usually also invest in patterns like feature flags, which you can read about in the feature flags from scratch guide.

How CORS Interacts with Modern API Architectures

In modern stacks with GraphQL, REST, and serverless functions, CORS sometimes looks different but boils down to the same concepts.

  • REST APIs: Classic patterns with route based authorization and method specific CORS rules. See the REST vs GraphQL beginners guide if you are still designing your API shape.
  • GraphQL gateways: Usually served from a single /graphql endpoint with POST requests. Preflight often happens because of JSON Content-Type and custom headers. Keep your CORS logic close to the gateway where cross origin clients connect.
  • Serverless or edge functions: CORS headers must be set per function. The good news is that you can often share a helper library across functions to keep your policy consistent. The edge functions and serverless architecture guide provides more context here.

Regardless of architecture, the browser’s enforcement rules do not change. You just decide whether you centralize CORS at a single edge entry point or replicate it per service.

The End. Treat CORS as a Contract, Not a Bug

When you grow from "CORS is a random source of red errors" to "CORS is the browser side contract that protects user data across origins," the entire debugging experience changes. You start reading DevTools headers like a log of a handshake rather than a pile of noise.

The key mindset shift is to treat CORS as architecture. Decide which frontends you trust, how they authenticate, and how you expose resources across domains. Then encode those decisions in code, infrastructure, and documentation rather than one off fixes.

As you design new APIs or refactor old ones, revisiting CORS alongside topics like versioning, performance, and SEO (for example, in the Next.js SEO best practices article) will help you ship systems that work reliably across teams and environments.

Takeaways

  1. Create a CORS helper module per backend stack: Wrap your header logic in a reusable function with a clear allow list of origins, methods, and headers. Use it consistently across Express apps, Next.js route handlers, and serverless functions.
  2. Document your cross origin topology early: For each environment, write down which origins talk to which APIs, and whether credentials are required. This turns "random CORS bugs" into configuration drift that you can detect in code review.
  3. Debug CORS via DevTools, not guesswork: The next time you hit a CORS problem, step through the preflight and response headers systematically, validating each part of the contract instead of randomly flipping flags.