Skip to content

    Chapter 22, Intermediate

    CORS

    CORS is a rule of the **browser**, not the server. It decides whether a site's JavaScript can read the response from your API. If configured incorrectly, it either blocks your frontend or exposes your backend to any site.

    The problem

    A origin is the combination of protocol, domain, and port. A frontend in http://localhost:5173 and an API in http://localhost:8000 are origins different. For security, the browser blocks JavaScript from one origin from reading responses from another, unless the server says it allows. Without this, you see in the console the error "blocked by CORS policy".

    The server allows it by responding with headers Access-Control-Allow-*. In FastAPI, this is a middleware:

    intermediario/cap22_cors.pylinhas 10 a 32
    from fastapi import FastAPI
    from fastapi.middleware.cors import CORSMiddleware
    from fastapi.testclient import TestClient
    
    app = FastAPI()
    app.add_middleware(
        CORSMiddleware,
        allow_origins=["http://localhost:5173"],
        allow_credentials=True,
        allow_methods=["GET", "POST"],
        allow_headers=["content-type"],
    )
    
    
    @app.get("/")
    def home():
        return {"mensagem": "CORS configurado"}
    
    
    cliente = TestClient(app)
    permitida = cliente.get("/", headers={"Origin": "http://localhost:5173"})
    print(permitida.headers.get("access-control-allow-origin"), permitida.headers.get("access-control-allow-credentials"))
    print(permitida.headers.get("vary"))
    
    Output
    http://localhost:5173 true
    Origin
    

    The disallowed origin

    When the origin does not is on the list, the server still executes the request and responds 200, but without the permission header. It is the browser that then hides the response from JavaScript:

    intermediario/cap22_cors.pylinhas 37 a 38
    negada = cliente.get("/", headers={"Origin": "http://malicioso.com"})
    print(negada.status_code, negada.headers.get("access-control-allow-origin"))
    
    Output
    200 None
    

    This means that CORS is not server security. Whoever calls from a script, from curl or from another server completely ignores CORS. It protects the user, in the browser, from a malicious site trying to read their session data from your API. Authentication and authorization remain the responsibility of your API.

    The preflight

    For "non-simple" requests (a POST with JSON, a DELETE, custom headers), the browser first makes a request with the method OPTIONS: "can I make this request?". This is the preflight. The middleware responds to it on its own:

    intermediario/cap22_cors.pylinhas 43 a 57
    preflight = cliente.options(
        "/",
        headers={
            "Origin": "http://localhost:5173",
            "Access-Control-Request-Method": "POST",
            "Access-Control-Request-Headers": "content-type",
        },
    )
    print(preflight.status_code, preflight.headers["access-control-allow-methods"])
    print(preflight.headers["access-control-max-age"])
    
    negado = cliente.options(
        "/", headers={"Origin": "http://localhost:5173", "Access-Control-Request-Method": "DELETE"}
    )
    print(negado.status_code, negado.text)
    
    Output
    200 GET, POST
    600
    400 Disallowed CORS method
    

    The DELETE is not in allow_methods, and the preflight returns 400: the browser does not send the actual request. The max-age indicates how long the browser can cache the preflight response.

    The dangerous configuration: `*` with credentials

    Many tutorials show allow_origins=["*"] together with allow_credentials=True, to "make the error disappear". The specification prohibits this combination, because it would hand over the user's session to any site. See what Starlette does with it:

    intermediario/cap22_cors.pylinhas 62 a 78
    app_aberto = FastAPI()
    app_aberto.add_middleware(
        CORSMiddleware,
        allow_origins=["*"],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )
    
    
    @app_aberto.get("/")
    def aberto():
        return {}
    
    
    qualquer = TestClient(app_aberto).get("/", headers={"Origin": "http://site-qualquer.com"})
    print(qualquer.headers.get("access-control-allow-origin"))
    
    Output
    http://site-qualquer.com
    

    Instead of refusing, Starlette returns the origin of who asked. The practical effect is to allow any site, with credentials. It's the configuration I see most in production code, and it nullifies the browser's protection.

    The rule I follow

    List the exact origins of your frontend (https://app.exemplo.com, plus http://localhost:5173 in development), and read the list from an environment variable (chapter 23). Use ["*"] only for public APIs, without cookies or credentials (an open data API). If your API uses session cookies, or if your frontend sends the token in a way that the browser attaches it automatically, the exact list is mandatory.

    WhatPurpose
    allow_originsWhich sites can read the response
    allow_methodsWhich verbs are allowed between origins
    allow_headersWhich headers the frontend can send
    allow_credentialsIf cookies and credentials can accompany the request
    allow_origin_regexOrigins by default (for example, all subdomains of exemplo.com)
    max_ageHow long the browser keeps the preflight response

    Exercise 1

    Two origins from a text

    Write montar_app(origens_csv), that receives a text like "https://a.com,https://b.com" and returns an app with CORS for exactly those origins. Check that both are allowed and a third is not.