Skip to content

Browser access and CORS

  • Lesson 9
  • 40 min
  • Module 3: Design a usable contract

What you will be able to do

Let a web page on a different address call your API from a browser, as a frontend team’s page will. Allow only the pages that you choose. And know exactly what that setting protects, and what it does not.

Before you startWhat to know and to have readyAbout 40 minutes

What you need to know

What you need on your computer

Tested with Python 3.14.6, FastAPI 0.142.2 and Chrome 141 on macOS.

An origin is the scheme, host and port of a page: http://localhost:5500 and http://127.0.0.1:8000 are two different origins, although both are on your computer.

Browsers have a protection called the same-origin rule: a page’s code can read responses only from its own origin. Without it, any web page that you open could use your open browser to read your email or your bank account in another tab. This is because the browser sends your cookies with each request.

Your API is a different origin from the page that a frontend team builds. So by default, the browser does not let their page read your API’s responses.

CORS (Cross-Origin Resource Sharing) is how a server says to the browser: “pages from these origins may read my responses”. The server says it with response headers, such as Access-Control-Allow-Origin.

Amira makes a small test page in a folder browser-test in her project. It sends one request to the API with fetch, the browser’s request function:

browser-test/index.html
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Ticket classifier test</title></head>
<body>
<h1>Ticket classifier test</h1>
<button id="send">Classify "Charged twice for March"</button>
<pre id="result">No request yet.</pre>
<script>
document.getElementById("send").addEventListener("click", async () => {
const result = document.getElementById("result");
try {
const response = await fetch("http://127.0.0.1:8000/v1/classify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subject: "Charged twice for March" }),
});
result.textContent = response.status + " " + (await response.text());
} catch (error) {
result.textContent = "The request failed: " + error;
}
});
</script>
</body>
</html>

She serves the page from a second origin with Python’s built-in web server, in a new terminal:

A new terminal, in browser-test
python -m http.server 5500

She opens http://localhost:5500 and selects the button. The page shows The request failed: TypeError: Failed to fetch. The developer tools’ Console tab says why:

Console
Access to fetch at 'http://127.0.0.1:8000/v1/classify' from origin 'http://localhost:5500' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

The API’s terminal shows something surprising:

API terminal
INFO: 127.0.0.1:59691 - "OPTIONS /v1/classify HTTP/1.1" 405 Method Not Allowed

The browser did not send the POST at all. It sent an OPTIONS request first.

For a request that could change data, such as a POST with JSON, the browser first asks the server for permission. This first request is the preflight: an OPTIONS request with the page’s origin in an Origin header. Only if the server answers with the right Access-Control-Allow-... headers does the browser send the real request.

The page from http://localhost:5500 calls fetch POST /v1/classify. The browser first sends a preflight, OPTIONS /v1/classify with the header Origin: http://localhost:5500, to the API on port 8000. If the origin is in ALLOWED_ORIGINS, the API answers 200 with Access-Control-Allow-Origin: http://localhost:5500, the browser sends the real POST, gets 200 and the JSON result, and gives the response to the page. If the origin is not allowed, the API answers without that header, and the page gets TypeError: Failed to fetch.
The browser asks before it sends. Only an allowed origin gets the real request and the response.
Text description of the diagram

The page calls fetch with a POST. The browser first sends a preflight to the API: OPTIONS /v1/classify, with Origin: http://localhost:5500. If that origin is in the API’s allowed list, the API answers 200 with Access-Control-Allow-Origin: http://localhost:5500. Then the browser sends the real POST, receives 200 and the JSON result, and gives it to the page. If the origin is not allowed, the API’s answer has no Access-Control-Allow-Origin header. Then the browser does not send the real request, and the page only gets TypeError: Failed to fetch.

FastAPI has a ready-made middleware for CORS. A middleware is code that runs around every request: before the route, and after it. The CORS middleware answers preflights, and adds the headers to the real responses.

The list of allowed origins is different for each place where the API runs: http://localhost:5500 on Amira’s computer, the frontend’s real address in production. So it is configuration, and it comes from an environment variable, as in Configuration and secrets:

ticket_api/main.py
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from ticket_api.classifier import KeywordClassifier
from ticket_api.errors import add_error_handlers
from ticket_api.routes import router
app = FastAPI(title="Ticket Classifier API")
app.state.classifier = KeywordClassifier()
add_error_handlers(app)
allowed_origins = [
origin.strip()
for origin in os.environ.get("ALLOWED_ORIGINS", "").split(",")
if origin.strip()
]
if allowed_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type"],
)
app.include_router(router)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
Part Why
ALLOWED_ORIGINS A list separated by commas, for example http://localhost:5500,https://help.example.com. Not set means “no browser page may call the API”, which is the safe default.
allow_origins Exact origins, with the scheme and the port. Never ["*"] (every origin) for an API that has keys or private data.
allow_methods, allow_headers Only what the pages need.

She stops the API with Ctrl+C, and starts it again with the variable:

Terminal window
ALLOWED_ORIGINS=http://localhost:5500 fastapi dev ticket_api/main.py

She selects the button again. The page shows 200 {"category":"billing","priority":1,...}, and the API’s terminal shows both requests:

API terminal
INFO: 127.0.0.1:59702 - "OPTIONS /v1/classify HTTP/1.1" 200 OK
INFO: 127.0.0.1:59703 - "POST /v1/classify HTTP/1.1" 200 OK

Now Amira pretends to be a page from a different origin, with curl. First the preflight:

Terminal (on Windows: curl.exe)
curl -i -X OPTIONS http://127.0.0.1:8000/v1/classify -H "Origin: https://evil.example" -H "Access-Control-Request-Method: POST"
Output (shortened)
HTTP/1.1 400 Bad Request
Disallowed CORS origin

Refused, as expected. Then the real request, from the same “origin”:

Terminal (on Windows: curl.exe)
curl -X POST http://127.0.0.1:8000/v1/classify -H "Origin: https://evil.example" -H "Content-Type: application/json" --data @requests/billing.json
Output
{"category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0"}

It works. curl is not a browser: it does not send a preflight, and it does not check the CORS headers. Neither does Postman, a Python script, or a server. CORS only controls what pages in a browser can read. It does not stop anyone from calling the API. To control who can call the API, you need authentication, such as the API key that you add in Module 6.

  1. Make browser-test/index.html with the code above.
  2. In a new terminal, move into browser-test, and run python -m http.server 5500.
  3. Open http://localhost:5500, open the developer tools’ Console, and select the button. You see the CORS error, and the API’s terminal shows OPTIONS ... 405.
  4. Add the CORS code to main.py. Stop the API, and start it again with ALLOWED_ORIGINS=http://localhost:5500.
  5. Select the button again. The page shows 200 and the result.
  6. Send the two curl requests with Origin: https://evil.example. The preflight is refused, and the real request works.
  7. Stop the test page’s server with Ctrl+C.

Serve the same page on port 5600 too (python -m http.server 5600 in a third terminal), and open http://localhost:5600. Predict what happens when you select the button. Then change ALLOWED_ORIGINS so that both test pages work, and nothing else.

Show the answer

On port 5600, the request is blocked: http://localhost:5600 is a different origin, and it is not in the list. Start the API with ALLOWED_ORIGINS=http://localhost:5500,http://localhost:5600. Note that http://127.0.0.1:5600 is still blocked: for CORS, localhost and 127.0.0.1 are different hosts, so they are different origins.

Common mistake

Allow every origin to make the error go away

Symptom: a CORS error blocks a page, and a quick search suggests allow_origins=["*"]. The error goes away.

Cause: * lets a page from any website read the API’s responses in a visitor’s browser. For a fully public API with no keys and no private data, that can be acceptable. Some APIs are called by a browser with credentials, or return data about a user. For those APIs, it lets any website read that data through its visitors.

Fix:

  1. Find the page’s exact origin: in the developer tools’ Network tab, the Origin request header of the preflight.
  2. Put exactly that origin in ALLOWED_ORIGINS, with its scheme and port.
  3. Keep a separate list for each place where the API runs. Production lists only the real frontend’s origin.

Check your understanding

A teammate says: 'Our API is safe from strangers, because CORS only allows our own website.' What is wrong with this?

Show the answer

CORS only tells browsers which pages may read the responses. Anyone can call the API directly with curl, Postman or a script, and get the answer, as the evil.example example showed. To stop strangers, the API needs authentication, for example an API key or user sign-in. CORS and authentication solve different problems.

Check your understanding

Module check: in your contract table, call each row for /v1/classify and /v1/categories, and confirm the status code and the body shape. Which row could a browser page not test, and why?

Show the answer

Every row gives the planned status code and, for errors, the shape {"error": {"code", "message", "fields"}}. A page can test them all once its origin is allowed. But a browser cannot send a body that is not valid JSON with fetch and JSON.stringify, and it cannot leave out the Origin header. Test those rows with curl or Postman.

Recap

  • An origin is a scheme, host and port. Browsers let a page read responses only from its own origin, unless the server allows more with CORS.
  • For a POST with JSON, the browser sends an OPTIONS preflight first. The CORS middleware answers it.
  • Allow exact origins from configuration (ALLOWED_ORIGINS). Do not use * for an API with keys or private data.
  • CORS is not authentication. Clients that are not browsers ignore it.

You have now designed a contract: clear routes, one error shape, and safe browser access. In the next module, you document it with OpenAPI and Swagger UI.

Next: OpenAPI and Swagger UI