LearnThatStack Ace your next interview

System Design Concepts · API Design

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.

CORSCORS is browser's checkthe browser guards its userBrowserapp.example.com200 ordersevil.exampleheldAPI serverAllow-Origin: app.example.comTerminal200 orders, in full600 s
CORSCORS is browser's checkBrowserapp.example.com200 ordersevil.exampleheldAPI serverAllow-Origin: app.example.comTerminal200 orders, in full600 s

CORS is browser's check

  1. A browser tab runs at https://app.example.com. The API is at https://api.example.com, a different origin, so the browser has its gate closed, its check on which origins may read a reply.
  2. The tab sends GET request to /orders with its Origin header, and the server responds with 200. The browser's CORS check, stops that reply, so the tab sees 'blocked by CORS'.
  3. The server sends the same reply, now with the header Allow-Origin: app.example.com. The header specify the one origin allowed to read the reply. The browser's check passes and the tab can read the reply.
  4. The tab wants to send PUT request to /orders/7 with JSON and an Authorization header, so the browser asks permission first with an OPTIONS preflight. The server answers with Allow-Methods, Allow-Headers and Max-Age 600, and the browser's CORS check keeps that permission for 600 seconds.
  5. The PUT reaches the server, and the browser's CORS check lets the 200 reply through. A second PUT skips the preflight OPTIONS request, because the browser keeps the preflight answer cached for 600 seconds.
  6. A terminal sends the same GET request to /orders, and no browser is there to run a CORS check. The server returns the full body. CORS don't protect the server.
  7. The server now copies any Origin it receives into Allow-Origin and allows credentials. A second tab at evil.example sends GET request to /orders with the user's cookie, so the browser lets the evil page read the user's orders.
  8. The server sets Allow-Origin back to app.example.com, so the browser lets the app tab read the reply and blocks the evil tab. The terminal still gets the full reply.

© LearnThatStack - diagrams may not be republished without permission.

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.

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.

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.

In interviews · 3 questions

Related Questions

Also helps with

← All concepts