The Browser Contract

CORS Explained: Same-Origin Policy, Preflights and Credentials

Why CORS protects the browser and is not a firewall for the server, what makes a preflight request, and why you cannot use a wildcard origin together with credentials.

Lesson 0415 min read#cors#http#security

On this page

  1. Origins and the same-origin policy
  2. Simple requests
  3. Preflighted requests
  4. Credentials
  5. Reading response headers
  6. A correct server setup (Express)
  7. Why "it works in Postman but not in the browser"
  8. Debugging checklist
  9. Is CORS a security control for your API?
  10. Common mistakes
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: CORS is a browser rule that lets a server say "yes, that other origin may read my response." It protects users from harmful pages. It is not a firewall for your API, and it does not stop requests from being sent.

CORS errors are some of the most common frontend problems, and some of the most misunderstood. Learn the two ideas underneath: the same-origin policy and the preflight. Then every error message becomes easy to read.

Two words appear everywhere in this lesson. An HTTP header is a line of extra information ("name: value") that travels with a request or a response, for example Content-Type: application/json. A cross-origin request is a request from a page on one origin to a different origin.

Origins and the same-origin policy

An origin is the scheme + host + port together. The scheme is http or https. The host is the site name. The port is the number after the colon.

URLSame origin as https://app.example.com?
https://app.example.com/pageYes (the path does not matter)
http://app.example.comNo (the scheme is different)
https://api.example.comNo (the host is different, even if it is a subdomain)
https://app.example.com:8443No (the port is different)

The same-origin policy (SOP) is a built-in browser rule that stops a page's scripts from reading data that came from a different origin. A page may often send requests to other origins. But its scripts may not read the responses, unless the other server agrees. Without this rule, any site you visit could use your logged-in session to read your email from another tab.

CORS (Cross-Origin Resource Sharing) is a set of HTTP headers that lets a server relax the same-origin policy for chosen origins. The response carries headers that tell the browser which origins may read it. Think of the same-origin policy as a locked door and CORS as the guest list the server gives to the guard (the browser).

Say two facts out loud in an interview. (1) CORS is enforced by the browser. curl, Postman and server-to-server calls ignore it. (2) For many requests, the browser sends the request first. It only hides the response from your script if the headers do not allow it. The effect on the server has already happened.

Simple requests

A simple request is a cross-origin request that the browser sends straight away, with no permission check first (no preflight). A request is simple if all of these are true:

  • The method is GET, HEAD or POST.
  • You set only CORS-safelisted headers by hand. These are headers the browser treats as harmless: Accept, Accept-Language, Content-Language, Content-Type (with limits, see the next point), and Range (with limits).
  • Content-Type is one of application/x-www-form-urlencoded, multipart/form-data or text/plain.

The browser sends a simple request directly, with an Origin header. Then it checks the response:

HTTP
GET /api/products HTTP/2Origin: https://app.example.comHTTP/2 200Access-Control-Allow-Origin: https://app.example.com

If Access-Control-Allow-Origin matches the origin (or is * for requests without credentials), JavaScript may read the body. If not, the browser blocks access and shows a CORS error.

Preflighted requests

All other requests are preflighted. A preflight is a permission check: before the real request, the browser sends an automatic OPTIONS request (a request that asks "what is allowed here?") and waits for the server's answer.

Common triggers:

  • Methods like PUT, PATCH or DELETE
  • Content-Type: application/json (JSON is not in the simple list, so almost every modern API call is preflighted)
  • Custom headers such as Authorization or X-Request-Id
HTTP
OPTIONS /api/orders HTTP/2Origin: https://app.example.comAccess-Control-Request-Method: POSTAccess-Control-Request-Headers: content-type, authorization

The server must answer with permission:

HTTP
HTTP/2 204Access-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETEAccess-Control-Allow-Headers: content-type, authorizationAccess-Control-Max-Age: 600Vary: Origin

Only if this check passes does the browser send the real POST. The browser remembers the answer for Access-Control-Max-Age seconds. Browsers set a limit on this (for example, a couple of hours in Chrome). So you do not pay for an extra round trip (one trip to the server and back) every time. If Max-Age is missing or low, you double the number of requests.

Here is the order of events:

TEXT
Browser                          API server  │── OPTIONS (preflight) ───────────▶│  │◀─ 204 + Allow-* headers ──────────│   ← permission check, no cookies, no body  │── POST /orders (real request) ───▶│  │◀─ 201 + Allow-Origin ─────────────│   ← the response must ALSO carry Allow-Origin

Credentials

Credentials means cookies, HTTP authentication and TLS client certificates. By default, a cross-origin fetch (the browser function that makes HTTP requests) does not send them. You must agree on the client. The server must agree too:

JavaScript
fetch("https://api.example.com/me", { credentials: "include" });
HTTP
Access-Control-Allow-Origin: https://app.example.com   ← must be the exact originAccess-Control-Allow-Credentials: true

Rules that interviewers like:

  • With credentials, Access-Control-Allow-Origin: * is not allowed. You must send back one specific origin.
  • The same is true for Allow-Headers: * and Allow-Methods: *. A wildcard (*) means "anything", but with credentials the browser reads it as a plain word, not as "anything".
  • If you send back the request's origin dynamically, add Vary: Origin. Vary is a response header that tells caches "this answer depends on this request header". If you do not add it, a cache may give one origin's response to another origin.
  • The cookie itself must also be allowed to travel. SameSite is a cookie setting that controls whether the cookie is sent on cross-site requests. A cross-site cookie needs SameSite=None; Secure (Secure means "only over HTTPS"), and rules against third-party cookies may block it (Lesson 03).

Reading response headers

By default, JavaScript can read only a short list of "safe" response headers. To let it read others (a Location, a page-count header like X-Total-Count, or RateLimit-Remaining), the server must list them:

HTTP
Access-Control-Expose-Headers: X-Total-Count, Location

A correct server setup (Express)

Express is a popular Node.js web server library. Middleware is a function that runs on every request before your route code, in the order you register it. The cors package is middleware that adds the CORS headers.

JavaScript
import cors from "cors";const allowed = new Set(["https://app.example.com", "https://staging.example.com"]);app.use(  cors({    origin(origin, cb) {      // no Origin header = non-browser client (curl, server-to-server): CORS does not apply      if (!origin || allowed.has(origin)) return cb(null, true);      cb(new Error("Origin not allowed"));    },    credentials: true,    methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],    allowedHeaders: ["Content-Type", "Authorization"],    exposedHeaders: ["X-Total-Count"],    maxAge: 600,  }),);

Use an allow-list: a fixed list of origins you trust, where everything not on the list is refused. Never copy any incoming origin back to the browser when credentials are on. That is the same as turning the protection off.

Why "it works in Postman but not in the browser"

Postman, curl and your backend are not browsers. They send the request and show you the response. They have no same-origin policy to enforce. The browser is different. It checks the CORS headers after the response arrives, and it refuses to give the response to your script. The fix is in the server's response headers, not in your frontend code.

Debugging checklist

  1. Read the exact console message. It usually names the missing header or the rule that failed.
  2. Open the Network tab. Look at the OPTIONS request first: its status and headers. Then look at the real request.
  3. Is the preflight failing? It must return a 2xx status (often 204) with the right Allow-* headers. Sometimes your auth middleware runs before CORS and rejects the OPTIONS request with 401, because it has no login. Then CORS fails. Put the CORS middleware first. Preflights carry no cookies and no Authorization header.
  4. Does the error response lack CORS headers? A 500, or a 502 from a proxy (a server that passes requests on) outside your app, has no Allow-Origin. The browser then shows a CORS error, even though the real problem is a server crash. Check the server logs.
  5. Redirects: a preflight must not be redirected. A redirect on the real request to a different origin also needs CORS headers.
  6. Credentials: use include on the client. On the server, use a specific origin plus Allow-Credentials: true.
  7. Different in production than on your machine? Check that the production origin is in the allow-list (including www, the scheme and the port). Also check that a CDN (a network of servers that caches and serves your files near users), a load balancer (a server that spreads requests over several servers) or an API gateway (one entry point in front of your APIs) is not removing or caching the headers (add Vary: Origin).

Is CORS a security control for your API?

No. An attacker's own script, curl, or a server ignores CORS completely. CORS protects users' browsers from other sites reading answers meant for a logged-in user. Your API still needs real authentication (checking who the caller is) and authorization (checking what that caller is allowed to do) on every request. Also, CORS is not CSRF protection. CSRF (cross-site request forgery) is an attack where a bad site makes your browser send a request to another site where you are logged in. A simple cross-site POST still arrives with cookies attached (Lesson 03).

Common mistakes

  1. Access-Control-Allow-Origin: * together with credentials. Browsers reject it.
  2. Copying the Origin header back for every origin while allowing credentials. This lets any site read user data.
  3. CORS middleware placed after auth, so preflights get 401.
  4. "Fixing" CORS in the frontend (setting headers on the request, or using no-cors mode, which gives you a response you cannot read, called an opaque response).
  5. Forgetting Vary: Origin when the response is different for each origin.
  6. No Max-Age, so every call starts with a preflight.
  7. Believing CORS blocks the request. It blocks reading the response. The server may already have acted.

Interview questions

What is an origin?

It is the combination of scheme, host and port. https://app.example.com and https://api.example.com are different origins. So are http and https, and so are different ports.

When does the browser send an OPTIONS preflight?

It sends one when a cross-origin request is not "simple". That means: a method other than GET, HEAD or POST, a custom header like Authorization, or a Content-Type that is not one of the three form types (so application/json triggers it). The preflight asks the server if the real method and headers are allowed.

Why does the request work in Postman but fail in the browser?

Only browsers enforce CORS. Postman has no same-origin policy. The browser stops your script from reading the response, because the server did not send the right Access-Control-Allow-* headers. Fix the headers on the server.

Scenario: cookies aren't sent on a cross-origin fetch. List the causes.
  1. The client did not set credentials: "include".
  2. The server did not send Access-Control-Allow-Credentials: true, or it used * instead of the exact origin.
  3. The cookie's SameSite is Lax or Strict, or it is None without Secure. Then the browser will not send it cross-site.
  4. Bonus: third-party cookie blocking, a Domain or Path mismatch, or the cookie was never saved because the browser ignored Set-Cookie in a CORS response without credentials.
Is CORS a security control for your API?

No. It protects users' browsers from bad sites that try to read logged-in responses. Anyone can call your API directly with a script or curl (a command-line tool that sends HTTP requests). So you still need authentication, authorization, validation and rate limiting on the server.

Why can't you combine Allow-Origin: with credentials?

A wildcard plus cookies would let any website read data as the logged-in user. The spec requires an exact origin when credentials are allowed. Keep an allow-list and send back the matching origin (with Vary: Origin).

A server error shows up in the console as a CORS error. How can that be?

Error responses from a crash, a gateway or a proxy often have no CORS headers. The browser sees a response without Allow-Origin and reports a CORS failure. This hides the real status. Look at the Network tab and the server logs to find the real 5xx error.

How do you avoid preflights altogether?

Make the API same-origin. Serve it under your domain, or pass it through your Next.js server (a proxy). Or keep your requests simple. In practice, same-origin proxying or a BFF (a server you own that sits between the browser and other APIs) is cleaner than loosening headers. You also get first-party cookies.

Scenario: CORS works for GET and POST but PUT fails in staging. Where do you look?

PUT triggers a preflight, so check the OPTIONS response. Access-Control-Allow-Methods must include PUT. Access-Control-Allow-Headers must list the custom headers you send. The preflight must return a 2xx status, and auth or a gateway rule must not block it. Also check that a proxy or load balancer in staging is not removing the headers, or caching a preflight response without Vary: Origin.

Key takeaways

  • Origin = scheme + host + port. CORS lets a server allow other origins to read its responses.
  • JSON, Authorization and methods that are not simple trigger an OPTIONS preflight. Cache it with Access-Control-Max-Age.
  • Credentials need credentials: "include", an exact origin, Allow-Credentials: true, and a cookie that can travel cross-site.
  • Put CORS middleware before auth. Always send Vary: Origin when you send an origin back. Use an allow-list.
  • CORS is not a firewall and not CSRF protection. Same-origin proxying avoids most of the pain.

What's next

Lesson 05: HTTP Caching. CORS responses can be cached, and so can almost everything else. We look at Cache-Control, validators and the CDN in between. We also look at why users see old pages after a deploy.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series