What is CORS?
The browser enforces CORS, not your server
The browser runs the same-origin policy to protect its users. And CORS headers tell the browser which origins may read the reply, but requests from curl, POSTMAN don't go through that check.
For this question · all 2 chapters
CORS is browser's check
- Your call to the endpoint works from Postman, but the same call from your page fails with "blocked by CORS policy". Nothing is wrong with the server. An origin is three parts: scheme, host and port. Here app.example.com and api.example.com differ only in the host, so the browser blocks your page.
- This first request is a plain GET /orders. The browser adds an Origin header, which specify the page that made the call. The server answers 200 and sends the body back. The reply reaches the browser, but the browser refuses to hand that response to the page.
- The server writes the Access-Control-Allow-Origin header, and the browser reads that header as a pass. The header speicfy one allowed origin, https://app.example.com. The browser lets the response reach the page because the server specified that origin. The allow list comes from the server, but the browser checks the list and decides.
- A PUT with a JSON body and an Authorization header is not a simple request. So the browser asks the server first, with an OPTIONS preflight carrying Access-Control-Request-Method and Access-Control-Request-Headers. The server answers with Allow-Methods, Allow-Headers and Max-Age. Two things trigger that preflight: any method other than GET, HEAD or POST, and any header the browser does not already allow.
- The browser now sends the PUT request itself. The server answers with 200, and the response passes the browser's check. The second PUT sends no preflight OPTIONS request, because Max-Age told the browser to remember the answer for 600 seconds, per URL and per method. Your dev tools then show fewer OPTIONS calls than you expected.
- A terminal sends the same GET /orders request, and no browser is involved. Nothing enforces CORS here, so the full response body comes back. The CORS check never runs for curl, Postman, a cron job or another server, so CORS does not protect the API. Authentication protects the API, and CORS decides if a page inside a browser may read the response.
- The browser refuses a wildcard when credentials are involved. So many servers copy back whatever Origin the request sent, and add Allow-Credentials: true. A tab at evil.example sends GET /orders request, and the browser attaches the session cookie if that cookie is allowed cross-site. The browser's check reads the copied Origin and allows the read, so the untrusted page gets the user's orders.
- Allow one named origin again, so the browser blocks the evil tab from reading the reply. The terminal still receives the reply, as nothing about the terminal changed. Keep a real list of allowed origins, and return the single origin that matched. Send Vary: Origin as well, so a cache never gives one origin's permission to another.
Preflight is two lists that must overlap
- The browser tab sends three requests, and only the PUT sends a preflight. The preflight carries a question, and the reply carries the answer. GET, HEAD and a form POST with no custom header are simple requests, so the browser sends them without a preflight. The PUT carries JSON and an Authorization header, so the browser asks first.
- The browser states exactly what it is about to send. Access-Control-Request-Method says PUT, and Access-Control-Request-Headers lists authorization and content-type. The preflight request carries those two lines ahead of the real call. Dev tools may show an OPTIONS request you didn't wrote, that one is from the browser.
- The server answers the preflight with two lists. Allow-Methods specify GET and PUT. Allow-Headers has authorization and content-type. The planned PUT and its headers are all on those lists, so the browser sends the real PUT request.
- The page now sends a custom header, X-Trace-Id, so the browser sends the preflight OPTIONS request again. The browser asks about x-trace-id, and the server's allowed list has no entry with that name. The browser then refuses to send the PUT, so the server log shows one OPTIONS and no PUT. The fix is to add x-trace-id to Access-Control-Allow-Headers.
- The server now lists x-trace-id, and the page sends its session cookie with the PUT. The server respond with Allow-Origin: *, and the browser rejects that star, because CORS does not allow a wildcard with a credential. The server must specify the origin, https://app.example.com, and add Allow-Credentials: true, so the page may read the response. The wildcard rule is why every credentialed API sends back a matched origin instead of a star.
- The browser reads Max-Age: 600 and keeps the preflight answer for ten minutes, one answer per URL and method. So the next PUT skips its preflight request and saves a round trip. Chromium caps that cache at two hours, Firefox at 24 hours. The page can read x-request-id in the reply only because the server listed that name under Access-Control-Expose-Headers.
Your call to the endpoint works from Postman, but the same call from your page fails with "blocked by CORS policy". Nothing is wrong with the server. An origin is three parts: scheme, host and port. Here app.example.com and api.example.com differ only in the host, so the browser blocks your page.
So, CORS is the browser protecting its user. The browser can ask permission before it makes the real call for some requests - the preflight. A blocked preflight ends the exchange right there. And in this case, the browser don't send the actual request at all.
© LearnThatStack - diagrams may not be republished without permission.
Want a quick review of the fundamentals? See the JavaScript cheatsheet.
110 of 126 JavaScript answers are in Pro.
Full answers, code samples, and AI explanations that go simpler or deeper. Cancel anytime.
- Full answers + code
- AI explanations, simpler or deeper
- 1,000 AI credits / month
- Cancel anytime