Skip to main content
FastAPI · lesson 12 of 19
FastAPI intermediate Lesson 12 of 19

Also part of: Web Security (OWASP Top 10, fix-side)

FastAPI Roles, CORS and Settings from Environment Variables

Guard FastAPI routes with role dependencies, run real CORS preflights that pass and fail, and load config from env vars and .env with pydantic-settings.

By SkillByExample · Published

fastapi python rbac authorization cors pydantic-settings configuration

Authorization, Browsers and Configuration

Authentication answered “who is calling?” This lesson covers three things a real API needs next. Roles decide what that caller may do. CORS decides which websites may call the API from a browser. Settings move the secret key and the allowed origins out of the code and into the environment.

Outputs come from FastAPI 0.143.0, Starlette 1.7.0, Pydantic 2.14.1 and pydantic-settings 2.15.0 on Python 3.12. Install with pip install fastapi uvicorn pydantic-settings httpx2. Every key below is a labelled dev-only placeholder.

Roles as a Dependency

The previous lesson checked the role inside the route. A function that returns a dependency lets every route ask for a role in one line. To keep the file short, current_user here reads an X-User header; in a real app it’s the JWT check from the previous lesson. Save as roles.py:

from typing import Annotated

from fastapi import Depends, FastAPI, Header, HTTPException

ROLES = {"ada": {"admin", "editor"}, "bob": {"editor"}, "cy": set()}
app = FastAPI()


def current_user(x_user: Annotated[str | None, Header()] = None) -> str:
    if x_user not in ROLES:  # stand-in for the JWT check from the auth lesson
        raise HTTPException(401, "Not authenticated", headers={"WWW-Authenticate": "Bearer"})
    return x_user


def require_role(role: str):
    def checker(user: Annotated[str, Depends(current_user)]) -> str:
        if role not in ROLES[user]:
            raise HTTPException(403, f"Requires role '{role}'")
        return user
    return checker


@app.put("/posts/{pid}")
def edit(pid: int, user: Annotated[str, Depends(require_role("editor"))]):
    return {"edited": pid, "by": user}


@app.delete("/posts/{pid}", dependencies=[Depends(require_role("admin"))])
def delete(pid: int):
    return {"deleted": pid}

Try each user against both routes with demo_roles.py:

from fastapi.testclient import TestClient

from roles import app

client = TestClient(app)
for user in ["ada", "bob", "cy", None]:
    h = {"X-User": user} if user else {}
    put_r, del_r = client.put("/posts/1", headers=h), client.delete("/posts/1", headers=h)
    print(f"{user!s:4} PUT {put_r.status_code}  DELETE {del_r.status_code}  {del_r.json()}")
$ python demo_roles.py
ada  PUT 200  DELETE 200  {'deleted': 1}
bob  PUT 200  DELETE 403  {'detail': "Requires role 'admin'"}
cy   PUT 403  DELETE 403  {'detail': "Requires role 'admin'"}
None PUT 401  DELETE 401  {'detail': 'Not authenticated'}

require_role("admin") runs once, when the route is defined, and returns a new checker function that remembers role. FastAPI then treats checker like any other dependency. Because checker itself depends on current_user, the checks run in order: no user gives a 401 before any role is looked at, and a known user without the role gets a 403. There are two ways to attach it. edit takes the returned user as a parameter because it needs the name. delete only needs the check, so it uses the route’s dependencies=[...] list and the value is thrown away. The demo lives in its own file so that Practice task 1 can import require_role from roles.py without running it.

CORS: What a Preflight Looks Like

A page served from https://app.example.com that calls your API at another origin (a different scheme, host or port) is making a cross-origin request. Before a non-simple request, such as a POST with an Authorization header, the browser first sends an OPTIONS preflight asking whether it’s allowed. Starlette’s CORSMiddleware answers it. Save as cors_app.py:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com"],
    allow_methods=["GET", "POST"],
    allow_headers=["Authorization", "Content-Type"],
    allow_credentials=True,
)


@app.get("/items")
def items():
    return [{"id": 1}]

Run uvicorn cors_app:app --port 8012, then send the same preflight a browser would:

$ curl -si -X OPTIONS http://127.0.0.1:8012/items -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: Authorization'
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 07:40:53 GMT
server: uvicorn
vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network
access-control-allow-methods: GET, POST
access-control-max-age: 600
access-control-allow-headers: Accept, Accept-Language, Authorization, Content-Language, Content-Type
access-control-allow-credentials: true
access-control-allow-origin: https://app.example.com
content-length: 2
content-type: text/plain; charset=utf-8

OK
$ curl -si -X OPTIONS http://127.0.0.1:8012/items -H 'Origin: https://unknown.example' -H 'Access-Control-Request-Method: POST'
HTTP/1.1 400 Bad Request
date: Tue, 13 Oct 2026 07:40:53 GMT
server: uvicorn
vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network
access-control-allow-methods: GET, POST
access-control-max-age: 600
access-control-allow-headers: Accept, Accept-Language, Authorization, Content-Language, Content-Type
access-control-allow-credentials: true
content-length: 22
content-type: text/plain; charset=utf-8

Disallowed CORS origin
$ curl -si -X OPTIONS http://127.0.0.1:8012/items -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: DELETE'
HTTP/1.1 400 Bad Request
date: Tue, 13 Oct 2026 07:40:53 GMT
server: uvicorn
vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network
access-control-allow-methods: GET, POST
access-control-max-age: 600
access-control-allow-headers: Accept, Accept-Language, Authorization, Content-Language, Content-Type
access-control-allow-credentials: true
access-control-allow-origin: https://app.example.com
content-length: 22
content-type: text/plain; charset=utf-8

Disallowed CORS method

The allowed preflight gets a 200 whose access-control-allow-origin names the caller exactly. access-control-max-age: 600 lets the browser cache the answer for 10 minutes. The allow-headers list includes Accept, Accept-Language and Content-Language, which you didn’t list, because browsers always allow those “simple” headers anyway. An unknown origin gets 400 Disallowed CORS origin and, crucially, no access-control-allow-origin header. A listed origin asking for DELETE gets Disallowed CORS method. In both cases the browser never sends the real request.

Now an ordinary GET from each origin:

$ curl -si http://127.0.0.1:8012/items -H 'Origin: https://unknown.example'
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 07:40:53 GMT
server: uvicorn
content-length: 10
content-type: application/json
access-control-allow-credentials: true
vary: Origin

[{"id":1}]
$ curl -si http://127.0.0.1:8012/items -H 'Origin: https://app.example.com'
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 07:40:53 GMT
server: uvicorn
content-length: 10
content-type: application/json
access-control-allow-credentials: true
access-control-allow-origin: https://app.example.com
vary: Origin

[{"id":1}]

The unknown origin still got a 200 with the data. CORS is not access control. The server ran the route and curl printed the body, because curl ignores CORS. What differs is the missing access-control-allow-origin header: a browser receives the same bytes but refuses to hand them to the page’s JavaScript. So CORS protects your users’ browsers from other websites; it does nothing against scripts. Protecting the data itself is the job of the authentication and role checks above.

The "*" Plus Credentials Trap

When CORS errors get in the way, a common “fix” is to allow everything. Save as broad_app.py:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
    allow_credentials=True,
)


@app.get("/items")
def items():
    return [{"id": 1}]

Run it with uvicorn broad_app:app --port 8013:

$ curl -si http://127.0.0.1:8013/items -H 'Origin: https://unknown.example'
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 07:41:00 GMT
server: uvicorn
content-length: 10
content-type: application/json
access-control-allow-origin: https://unknown.example
access-control-allow-credentials: true
vary: Origin

[{"id":1}]
$ curl -si -X OPTIONS http://127.0.0.1:8013/items -H 'Origin: https://unknown.example' -H 'Access-Control-Request-Method: DELETE'
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 07:41:04 GMT
server: uvicorn
vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network
access-control-allow-methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, QUERY
access-control-max-age: 600
access-control-allow-credentials: true
access-control-allow-origin: https://unknown.example
content-length: 2
content-type: text/plain; charset=utf-8

OK

You might expect access-control-allow-origin: *. Browsers reject * on a request that carries cookies, so Starlette copies the caller’s origin back instead, together with allow-credentials: true. In practice that tells the browser: any website may call this API with the user’s cookies and read the answer. A page on any site the user visits could read their data through your API. The preflight approves DELETE from the same unknown origin, because allow_methods=["*"] expands to every method Starlette knows, including the newer QUERY. The fix is the configuration from cors_app.py: list the real front-end origins, and allow only the methods and headers they use. If you really need a public API open to every origin, keep ["*"] but drop allow_credentials, and authenticate with an Authorization header that pages can’t send on someone else’s behalf. The next section moves the origin list into settings so that each environment can set its own.

Settings from the Environment

The secret key and the allowed origins differ between your laptop, staging and production, and the key must never be committed. pydantic-settings reads typed settings from environment variables. Save as config.py:

from pydantic import SecretStr, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env")
    secret_key: SecretStr
    cors_origins: list[str] = ["http://localhost:5173"]
    token_minutes: int = 15
    debug: bool = False


if __name__ == "__main__":
    try:
        settings = Settings()
    except ValidationError as exc:
        raise SystemExit(exc)
    print(settings)
    print(settings.secret_key.get_secret_value()[:12] + "...")
$ python config.py
1 validation error for Settings
secret_key
  Field required [type=missing, input_value={}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.14/v/missing
$ APP_SECRET_KEY=dev-only-not-a-real-key APP_TOKEN_MINUTES=thirty python config.py
1 validation error for Settings
token_minutes
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='thirty', input_type=str]
    For further information visit https://errors.pydantic.dev/2.14/v/int_parsing
$ APP_SECRET_KEY=dev-only-not-a-real-key APP_TOKEN_MINUTES=30 APP_DEBUG=yes APP_CORS_ORIGINS='["https://app.example.com"]' python config.py
secret_key=SecretStr('**********') cors_origins=['https://app.example.com'] token_minutes=30 debug=True
dev-only-not...

Failing at startup is the point. With no APP_SECRET_KEY set, the app refuses to start and names the missing setting. (raise SystemExit(exc) prints just the error and exits with status 1. Without it you’d get the same message under a traceback.) Code using os.environ.get("SECRET_KEY", "changeme") would instead start quietly with a guessable key. Every value arrives as a string and is converted by type, so "thirty" fails as an int, while "yes" becomes True for a bool. Complex types such as list[str] are read as JSON, hence the brackets and quotes in APP_CORS_ORIGINS. SecretStr prints as asterisks in repr and in logs, and you have to call get_secret_value() to use it, which keeps the key out of tracebacks and debug output.

env_file=".env" also reads a local file, which keeps development values out of your shell history. Add .env to .gitignore. With this .env next to config.py:

APP_SECRET_KEY=dev-only-from-dotenv
APP_TOKEN_MINUTES=60
APP_CORS_ORIGINS=["https://app.example.com"]
$ python config.py
secret_key=SecretStr('**********') cors_origins=['https://app.example.com'] token_minutes=60 debug=False
dev-only-fro...
$ APP_TOKEN_MINUTES=5 python config.py
secret_key=SecretStr('**********') cors_origins=['https://app.example.com'] token_minutes=5 debug=False
dev-only-fro...

A real environment variable beats the .env file, so a deployment can override any development value without editing it.

Settings in the App

FastAPI’s docs wrap Settings() in a cached function and inject it as a dependency. Save as settings_app.py, next to config.py and .env:

from functools import lru_cache
from typing import Annotated

from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient

from config import Settings


@lru_cache
def get_settings() -> Settings:
    print("reading settings")
    return Settings()


app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=get_settings().cors_origins)


@app.get("/config")
def show(settings: Annotated[Settings, Depends(get_settings)]):
    return {"token_minutes": settings.token_minutes, "secret_key": settings.secret_key}


client = TestClient(app)
for _ in range(2):
    r = client.get("/config", headers={"Origin": "https://app.example.com"})
    print(r.json(), r.headers.get("access-control-allow-origin"))
$ python settings_app.py
reading settings
{'token_minutes': 60, 'secret_key': '**********'} https://app.example.com
{'token_minutes': 60, 'secret_key': '**********'} https://app.example.com

“reading settings” printed once: lru_cache builds the Settings object on the first call (here, while setting up the middleware) and returns the same object afterwards, so the environment and .env aren’t re-read on every request. The CORS origins now come from the environment, but they are read once at import, when add_middleware runs, so a test that overrides get_settings with dependency_overrides changes what routes receive, not the middleware’s allowed origins. Even returning the SecretStr by mistake only sends asterisks. Injecting settings with Depends(get_settings) instead of importing a global has a second payoff: a test can swap in different settings with app.dependency_overrides, which Dependency Injection shows.

Practice

  1. Protect a whole /admin router with the admin role, without adding anything to its individual routes.
Solution

Save as p1.py next to roles.py:

from fastapi import APIRouter, Depends, FastAPI
from fastapi.testclient import TestClient

from roles import require_role

admin = APIRouter(prefix="/admin", dependencies=[Depends(require_role("admin"))])


@admin.get("/users")
def users():
    return ["ada", "bob", "cy"]


@admin.post("/reindex")
def reindex():
    return {"status": "started"}


app = FastAPI()
app.include_router(admin)
client = TestClient(app)
for user in ["ada", "bob"]:
    h = {"X-User": user}
    print(user, client.get("/admin/users", headers=h).status_code, client.post("/admin/reindex", headers=h).status_code)
$ python p1.py
ada 200 200
bob 403 403

A router’s dependencies run before every route on it, so a new admin route added later is protected automatically. That’s safer than remembering to add the check each time.

  1. Allow CORS from every HTTPS subdomain of example.com, without listing each one, and check that look-alike origins are refused.
Solution

Save as p2.py:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient

app = FastAPI()
app.add_middleware(
    CORSMiddleware,
    allow_origin_regex=r"https://[a-z0-9-]+\.example\.com",
    allow_methods=["GET"],
)


@app.get("/items")
def items():
    return []


client = TestClient(app)
for origin in ["https://app.example.com", "https://staging.example.com",
               "https://example.com.other.test", "http://app.example.com"]:
    r = client.options("/items", headers={"Origin": origin, "Access-Control-Request-Method": "GET"})
    print(f"{origin:32} {r.status_code} {r.headers.get('access-control-allow-origin')}")
$ python p2.py
https://app.example.com          200 https://app.example.com
https://staging.example.com      200 https://staging.example.com
https://example.com.other.test   400 None
http://app.example.com           400 None

Starlette checks allow_origin_regex with fullmatch, so the whole origin must match and example.com.other.test can’t sneak through on a prefix. Plain http is refused because the pattern says https. Escape the dots: an unescaped . matches any character.

  1. Operators like to write APP_CORS_ORIGINS=https://a.example.com,https://b.example.com, without JSON. With config.py that fails with SettingsError: error parsing value for field "cors_origins". Accept the comma-separated form.
Solution

Save as p3.py:

from typing import Annotated

from pydantic import field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_")
    cors_origins: Annotated[list[str], NoDecode] = []

    @field_validator("cors_origins", mode="before")
    @classmethod
    def split_commas(cls, value):
        if isinstance(value, str):
            return [o.strip() for o in value.split(",") if o.strip()]
        return value


print(Settings().cors_origins)
$ APP_CORS_ORIGINS='https://app.example.com, https://admin.example.com' python p3.py
['https://app.example.com', 'https://admin.example.com']

NoDecode tells pydantic-settings not to JSON-decode this field, so the raw string reaches a before validator, the same pattern Validators and Serialization used for comma-separated tags.

Frequently Asked Questions

How do I restrict a FastAPI route to one role?
Write a function that takes the role name and returns a dependency. The inner dependency depends on your current-user dependency and raises HTTPException(403) when the user lacks the role. Use it as Depends(require_role('admin')) on a parameter, in a route's dependencies list, or in an APIRouter's dependencies to protect every route on that router.
Why does my FastAPI CORS preflight return 400 Disallowed CORS origin?
Starlette's CORSMiddleware answers an OPTIONS preflight with 400 and the text 'Disallowed CORS origin' when the request's Origin header isn't in allow_origins and doesn't fully match allow_origin_regex. A method missing from allow_methods gives 'Disallowed CORS method'. Check that the origin matches exactly, including scheme and port, with no trailing slash.
Is allow_origins=['*'] with allow_credentials=True safe in FastAPI?
No. Browsers forbid the literal '*' with credentials, so Starlette instead copies whatever Origin the request sent into Access-Control-Allow-Origin and adds Access-Control-Allow-Credentials: true. Any website a logged-in user visits can then read your API's responses with their cookies. List your real front-end origins explicitly instead.
Does CORS protect my API from curl or other servers?
No. CORS is enforced by browsers, which refuse to let a page's JavaScript read a cross-origin response unless the server's headers allow it. curl, scripts and other servers ignore those headers and get the response anyway. Authentication and authorization are what protect an API; CORS only controls which web pages may call it from a browser.
How do I load FastAPI settings from environment variables?
Install pydantic-settings and subclass BaseSettings with typed fields. Each field is read from an environment variable of the same name, case-insensitively, with an optional env_prefix and an env_file such as .env. A required field that is missing raises a ValidationError at startup. Wrap Settings() in an lru_cache function and inject it with Depends so it is read once and can be overridden in tests.