Under the Hood
Networking

CORS: the browser's cross-origin contract

What an origin actually is, why CORS exists, simple vs preflighted requests and every header involved, why curl and Postman never see any of this, how S3/CDN CORS differs from your app's CORS, the multitenant subdomain pattern, and the misconfigurations that turn CORS into a real vulnerability.

You've seen the error: "blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present." The same call works fine in curl, works fine in Postman, and fails only in the browser. Nothing is wrong with your server logic — this is the browser doing exactly what it's designed to do, and the fastest way through it is to stop thinking of it as an error and start thinking of it as a permission slip your server hands the browser, request by request.

What an origin actually is

An origin is scheme + host + port, compared as an exact string. Change any one part and it's a different origin to the browser, full stop:

ABSame origin?
https://app.example.comhttp://app.example.comNo — scheme differs
https://app.example.comhttps://app.example.com:8443No — port differs
https://app.example.comhttps://api.example.comNo — host differs
https://app.example.com/dashboardhttps://app.example.com/settingsYes — path is irrelevant to origin

That third row is the one people trip over. app.example.com and api.example.com feel like "the same site" — same company, same brand, same cookie jar in casual conversation — but to a browser's origin comparison they're as unrelated as two strangers' domains. There is no concept of a parent domain, no wildcard, no hierarchy here. api.cvt.tprm.digital, spark18.cvt.tprm.digital, and a doubly-nested a.b.spark18.cvt.tprm.digital are three fully distinct origins. None of them get any special treatment because they share a suffix — a server has to say, explicitly and per-request, which exact origins it trusts. Hold onto that "exact string, no hierarchy" rule; the next lesson is entirely about the one browser mechanism that works the opposite way.

Why the browser bothers: ambient authority

Every request a browser sends to a host automatically carries that host's cookies and any cached HTTP auth — the browser does this without your JS asking. That's convenient and also dangerous: if a page on evil.com could freely read the response from your-bank.com/api/balance, it would ride along on your logged-in session, no password needed, invisible to you. The same-origin policy (SOP) is the browser's default answer: JavaScript may only read responses from its own origin. CORS (Cross-Origin Resource Sharing) is the escape hatch — a way for a server to tell the browser "actually, this specific other origin may read my response." Two things to internalize before anything else:

  • CORS is enforced by the browser. It is granted by the server. Your server can't force a browser to block anything, and a browser will never let cross-origin JS read a response unless the server said so.
  • It governs reading the response, not sending the request. The request itself almost always reaches your server — the browser blocks its own JS from seeing what came back. That distinction matters enough that it gets its own section on attacks below.

Simple requests vs the preflight

Not every cross-origin call is treated the same. A "simple" request is one the web already allowed before CORS existed (plain HTML forms have always been able to POST cross-origin), so the browser just sends it and checks the answer afterward. A request only qualifies as simple if all of these hold:

  • Method is GET, HEAD, or POST
  • No headers beyond a short allowlist (Accept, Accept-Language, Content-Language, Content-Type)
  • Content-Type, if present, is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain

Notice application/json is not on that list. Almost every modern API call — JSON body, an Authorization header, a custom X-Tenant-Subdomain header, any PUT/PATCH/DELETE — fails at least one of those conditions. Which means almost every real API call today triggers a preflight: before sending the actual request, the browser sends its own OPTIONS request asking permission, and only proceeds if the server answers correctly.

OPTIONS /users/me HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization, x-tenant-subdomain
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, x-tenant-subdomain
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

That preflight is a full extra round trip that happens before your real handler even runs — on a server 70ms away, that's another 70ms tax on every non-simple call, every time, unless the browser is allowed to cache the answer. Access-Control-Max-Age is that cache: 600 here means the browser won't re-ask for ten minutes for the same method/header combination on that origin.

Build a request and watch it pass or fail. Add an Authorization header or switch to PUT and the request stops being “simple” — now it needs a preflight the server config has to satisfy. Then turn on both cookies and Allow-Origin: * to see the browser refuse the contradiction outright.

Browser request — from app.example.com
Method
Content-Type
Server CORS config — api.example.com
Access-Control-Allow-Origin
Simple request → browser sends it directly (no preflight)
Server responds; browser checks Access-Control-Allow-Origin before exposing it
✓ request allowedA simple request — sent directly, and the response is readable because the origin is allowed.

Almost every real API call — JSON body, an Authorization header, any PUT/DELETE — fails the “simple request” test and triggers a preflight OPTIONS the server must answer correctly before the real call runs. And Allow-Origin: * with credentials is a contradiction the browser refuses outright — try enabling both. CORS is the server declaring, per origin, exactly which methods and headers it will accept.

The header vocabulary

HeaderSent byMeaning
OriginBrowser (request)The calling page's origin — informational for the server, not optional, can't be spoofed by page JS
Access-Control-Request-MethodBrowser (preflight)"I'm about to send this method"
Access-Control-Request-HeadersBrowser (preflight)"I'm about to send these custom headers"
Access-Control-Allow-OriginServer (response)The one origin (or *) allowed to read this response
Access-Control-Allow-MethodsServer (preflight response)Methods this origin may use
Access-Control-Allow-HeadersServer (preflight response)Headers this origin may send
Access-Control-Allow-CredentialsServer (response)Whether cookies/auth may ride along — forces Allow-Origin to be an exact echoed origin, never *
Access-Control-Expose-HeadersServer (response)Response headers JS is allowed to read beyond the default safelist (Content-Type, etc.)
Access-Control-Max-AgeServer (preflight response)How long the browser may cache this preflight answer
Vary: OriginServer (response)Tells caches/CDNs this response differs by Origin — essential once you echo dynamically (below)

You'll also see a newer, related family in the Network tab that isn't CORS at all but shows up right next to it: Sec-Fetch-Site, Sec-Fetch-Mode, and Sec-Fetch-Storage-Access. These are the browser telling the server what kind of request this is (same-site, cross-site, navigate, cors) and whether the current context even has access to unpartitioned cookie storage for this pairing. They're covered properly in the cookies lesson — worth knowing they exist so you don't mistake them for a CORS header when reading real traffic.

Wildcard vs echo — and why credentials forbid *

Access-Control-Allow-Origin: * means "any origin on earth may read this." That's fine for a public, anonymous API (a weather endpoint, public docs). It is specifically disallowed the moment Access-Control-Allow-Credentials: true is also set — the spec forbids the combination, and browsers enforce it by refusing to expose the response. That's not a bug to work around; it's the platform stopping you from accidentally letting every website on earth read cookie-authenticated data.

So any credentialed cross-origin API — which is most multitenant SaaS APIs — has to echo back the exact validated origin instead of a static value:

if (isAllowedOrigin(request.origin)) {
  response.setHeader('Access-Control-Allow-Origin', request.origin) // the exact string, not '*'
  response.setHeader('Access-Control-Allow-Credentials', 'true')
  response.setHeader('Vary', 'Origin') // this response's headers depend on Origin — say so
}

Vary: Origin isn't decoration — without it, a CDN or reverse proxy caching this response could serve tenant A's Access-Control-Allow-Origin: https://a.example.com back to tenant B's browser, which the browser will then correctly reject (wrong origin echoed), producing a support ticket that looks exactly like a server bug but is actually a caching bug.

The multitenant pattern: suffix match, not wildcard

A subdomain-per-tenant product can't hardcode one origin, and * is off the table because of credentials. The standard pattern is a dynamic per-request allowlist check: maintain a list of allowed suffixes (.example.com, .reseller-brand.com for a white-labeled reseller), and on every request, check whether the incoming Origin header's hostname ends in one of them — then echo that exact origin if it matches, and omit the header entirely if it doesn't (an absent header is the correct "no" — the browser blocks by default).

Non-browser callers don't see any of this

CORS is a restriction the browser places on JavaScript running on a webpage. curl has no page and no JS sandbox to protect — it sends the raw HTTP request and prints whatever comes back, headers and all, with zero concept of "origin" as a security boundary. Postman, a mobile app's native networking stack (URLSession, OkHttp), and one of your own backend services calling another backend service over fetch/axios are all in the same boat: nothing mediates the read, so nothing can block it.

This is the entire explanation for "works in Postman, fails in browser" — the server sent the identical bytes in both cases. The difference is entirely on the reading side: a browser's JS asked permission first and didn't get it; curl never asked. It also means curl tests can hide bugs — curl never sends a preflight OPTIONS either, so a curl-only test suite can pass green while a real browser user hits a broken preflight your tests never exercised.

S3, CDNs, and "who answers the preflight" as an infra question

Object storage CORS looks different but runs the identical protocol. An S3 bucket's CORS configuration is a static, declarative rule list (AllowedOrigins, AllowedMethods, AllowedHeaders, MaxAgeSeconds) attached to the bucket — you never deploy code for it. When a browser sends a preflight OPTIONS (or the real GET) straight to the bucket, S3's own request-handling layer evaluates your rules and echoes the matching origin back, exactly like an app server would, except the "server" here is a managed service reading a config blob instead of running your handler. A CloudFront distribution, an API Gateway, or a hand-configured reverse proxy in front of your app can do the same thing: answer CORS at the edge, before a request ever reaches your application code.

When CORS misconfiguration becomes a vulnerability

  • Blind reflection. Access-Control-Allow-Origin: <whatever Origin the browser sent>, unconditionally, plus Allow-Credentials: true, is the single most common real-world CORS vulnerability. It's functionally identical to * + credentials — any website can now make a victim's browser fetch your API with the victim's own cookies and read the authenticated JSON straight back into attacker-controlled JS. This has shown up in bug-bounty reports against real companies, not as a theoretical issue.
  • The null origin trick. A sandboxed iframe or a local file:// page sends Origin: null. A server that allowlists the literal string "null" for developer convenience hands that same hole to any attacker who can get content running in a sandboxed context.
  • Unanchored allowlist checks. Covered above — includes()/substring checks and regexes without ^/$ anchors both have well-known bypasses.
  • CORS is not CSRF protection. This is the single most common conceptual mix-up. CORS decides whether JS can read a response — it does nothing to stop the browser from sending a cross-site, state-changing request in the first place. A plain HTML <form method="POST"> on evil.com targeting your API still fires, still carries the ambient session cookie, and still executes on your server — the attacker just can't read the JSON reply, which for a request whose side effect is the goal (transfer funds, change an email, delete an account) is irrelevant. CORS and CSRF defenses solve different halves of the same ambient-authority problem; the actual CSRF defense — SameSite cookies and CSRF tokens — is the subject of the next lesson.

Checklist

  • Allowlist exact origins or match suffixes with a real URL parse and a boundary check — never substring, never an unanchored regex, never blind reflection.
  • Never pair Access-Control-Allow-Origin: * with Allow-Credentials: true — the platform already refuses this; don't fight it by trying to work around it.
  • Add Vary: Origin the moment you echo dynamically, so caches/CDNs don't leak one tenant's header value to another.
  • Keep Allow-Headers/Allow-Methods to exactly what you use — it's an allowlist, not documentation.
  • Set a sane Access-Control-Max-Age (minutes, not zero, not a year) to cut the preflight round trip on repeat calls without freezing your ability to change policy.
  • Know who actually answers CORS in production — your app, or an edge/proxy layer in front of it — and keep that config in version control wherever it really lives.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Why does `api.example.com` get no special treatment from `app.example.com`'s CORS check, even though they're 'the same company'?
  2. What exactly makes a request 'simple' vs preflighted, and why does almost every real JSON API call end up preflighted?
  3. Why can't you combine `Access-Control-Allow-Origin: *` with `Access-Control-Allow-Credentials: true`, and what must a credentialed multi-origin API do instead?
  4. A curl request to your API succeeds and a browser fetch to the same URL fails with a CORS error. Whose fault is it, and what actually happened on the wire in each case?
  5. Why is `Vary: Origin` required once you start echoing the Origin header dynamically?
  6. Explain, precisely, why fixing your CORS headers does nothing to stop a CSRF attack against a state-changing endpoint.