FastAPI CORS error on localhost (tested)

A FastAPI CORS error from a local frontend usually comes from one of four things we reproduced: no CORSMiddleware (preflight gets 405), an origin with a trailing slash or 127.0.0.1 instead of localhost (400 Disallowed CORS origin), or the default allow_methods, which only allows GET.

Each case sends the preflight a browser sends before a JSON POST from http://localhost:3000.

1. Six configurations, one preflight

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient
pre = {"Origin": "http://localhost:3000", "Access-Control-Request-Method": "POST",
       "Access-Control-Request-Headers": "content-type"}
def make(**cors):
    app = FastAPI()
    if cors: app.add_middleware(CORSMiddleware, **cors)
    @app.post("/items")
    def items(): return {"ok": True}
    return TestClient(app)
def show(label, c):
    r = c.options("/items", headers=pre)
    print(f"--- {label}: preflight {r.status_code}, allow-origin={r.headers.get('access-control-allow-origin')}, body={r.text[:40]!r}")
show("no CORSMiddleware", make())
show("allow_origins=['http://localhost:3000/'] (trailing slash)", make(allow_origins=["http://localhost:3000/"], allow_methods=["*"], allow_headers=["*"]))
show("allow_origins=['http://127.0.0.1:3000']", make(allow_origins=["http://127.0.0.1:3000"], allow_methods=["*"], allow_headers=["*"]))
show("allow_origins=['http://localhost:3000'], default methods", make(allow_origins=["http://localhost:3000"]))
show("allow_origins=['http://localhost:3000'], methods/headers *", make(allow_origins=["http://localhost:3000"], allow_methods=["*"], allow_headers=["*"]))
show("allow_origins=['*'] + allow_credentials=True", make(allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]))

Output:

--- no CORSMiddleware: preflight 405, allow-origin=None, body='{"detail":"Method Not Allowed"}'
--- allow_origins=['http://localhost:3000/'] (trailing slash): preflight 400, allow-origin=None, body='Disallowed CORS origin'
--- allow_origins=['http://127.0.0.1:3000']: preflight 400, allow-origin=None, body='Disallowed CORS origin'
--- allow_origins=['http://localhost:3000'], default methods: preflight 400, allow-origin=http://localhost:3000, body='Disallowed CORS method'
--- allow_origins=['http://localhost:3000'], methods/headers *: preflight 200, allow-origin=http://localhost:3000, body='OK'
--- allow_origins=['*'] + allow_credentials=True: preflight 200, allow-origin=http://localhost:3000, body='OK'

What each result means:

Working setup for local development

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
    allow_methods=["*"],
    allow_headers=["*"],
    allow_credentials=True,  # only if you send cookies
)

That is the passing configuration from block 1 with the second local origin added. If the browser still reports CORS, check the actual response in the Network tab: a 500 from your endpoint can also arrive without CORS headers, and the browser reports it as a CORS error.

Related: CORS error messages explained · CORS preflight (OPTIONS) · FastAPI 422.

Frequently asked questions

Why does FastAPI return 405 for OPTIONS requests?

No CORSMiddleware is installed, so the browser's preflight OPTIONS request reaches your route, which has no OPTIONS handler.

Why do I get Disallowed CORS origin from FastAPI?

The Origin header does not exactly match an entry in allow_origins. Remove trailing slashes and list both http://localhost:PORT and http://127.0.0.1:PORT if you use both.

Why do I get Disallowed CORS method?

CORSMiddleware's default allow_methods is GET only. Pass allow_methods=['*'] or list POST, PUT, DELETE.

Written with AI assistance. Measured on 2026-10-23 with FastAPI 0.161.1, Starlette 1.9.0 and Python 3.14.7 (TestClient, python:3-alpine in Docker). Each block was run verbatim and the output pasted unedited. PlainHeaders is our extension; Python and FastAPI are free.