FastAPI · lesson 12 of 19
Beginner
6Intermediate
10- 7 FastAPI Pydantic Validation: Field Constraints and Nested Models
- 8 FastAPI Validators and Serialization with Pydantic
- 9 FastAPI CRUD with SQLModel and SQLite
- 10 FastAPI Database Migrations with Alembic and SQLModel
- 11 FastAPI Authentication: Password Hashing, OAuth2 and JWT
- 12 FastAPI Roles, CORS and Settings from Environment Variables
- 13 FastAPI Dependency Injection: Depends, yield and Overrides
- 14 Testing FastAPI with pytest, TestClient and httpx
- 15 FastAPI with Async PostgreSQL: Pools, Transactions, and Real Errors
- 16 Sync vs Async Endpoints in FastAPI, Measured
Advanced
3Also part of FastAPI
6Also 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
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
- Protect a whole
/adminrouter 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.
- 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.
- Operators like to write
APP_CORS_ORIGINS=https://a.example.com,https://b.example.com, without JSON. Withconfig.pythat fails withSettingsError: 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.