In this post
What this post covers
How this was tested
FastAPI 0.142.3, Pydantic 2.13.3, Python 3.11, using fastapi.testclient. Every output shown below is what the test client returned.
Headers carry the things that are not part of your data: auth tokens, request IDs, trace context, client versions, caching rules. FastAPI reads them with Header(), validates them like any other parameter, and documents them in OpenAPI. Here are the patterns, each run against a real app.
Read one header
from typing import Annotated
from fastapi import FastAPI, Header
app = FastAPI()
@app.get("/ua")
def ua(user_agent: Annotated[str | None, Header()] = None):
return {"user_agent": user_agent}
A request with User-Agent: curl/8 returns {"user_agent": "curl/8"}. Sending user-agent: lower works too: header names are case-insensitive in HTTP, and FastAPI matches them that way.
The parameter is user_agent, but the header is User-Agent. That’s the underscore conversion, the thing that trips most people up.
The underscore trap
Python names can’t contain hyphens, so FastAPI converts underscores in the parameter name to hyphens when it looks up the header. x_api_key reads X-API-Key.
If a client really sends a header with underscores, turn the conversion off:
@app.get("/raw")
def raw(strange_header: Annotated[str | None, Header(convert_underscores=False)] = None):
return {"strange_header": strange_header}
| Header sent | Result |
|---|---|
strange_header: u |
{"strange_header": "u"} |
strange-header: d |
{"strange_header": null} |
Avoid underscores in header names anyway. Many proxies drop them silently: nginx, for example, ignores headers containing underscores unless underscores_in_headers on is set. The request then reaches FastAPI without the header and your code sees None in production but not locally.
Required headers
Leave out the default and the header becomes required:
@app.get("/req")
def req(x_api_key: Annotated[str, Header()]):
return {"ok": x_api_key}
Without the header, FastAPI returns 422 with loc: ["header", "x-api-key"] and type: "missing". With X-API-Key: k it returns {"ok": "k"}.
For authentication, a 422 is the wrong signal: clients expect 401. Use a dependency instead (below).
Repeated headers
Some headers can appear more than once. Declare a list and you get every value:
@app.get("/token")
def tok(x_token: Annotated[list[str] | None, Header()] = None):
return {"x_token": x_token}
Sending X-Token: a and X-Token: b returns {"x_token": ["a", "b"]}. With str instead of list[str] you would get only one of them.
Group headers in a Pydantic model
Since FastAPI 0.115 you can declare several headers as one model:
from pydantic import BaseModel
class Common(BaseModel):
x_request_id: str | None = None
traceparent: str | None = None
@app.get("/model")
def model(h: Annotated[Common, Header()]):
return h
A request with X-Request-Id: r1 returns {"x_request_id": "r1", "traceparent": null}, and the OpenAPI schema lists x-request-id and traceparent as header parameters, so they show up in /docs.
The trap: FastAPI’s docs show model_config = {"extra": "forbid"} to reject unknown headers. Try it with a normal client and every request fails. In our test, the same request returned 422 with extra_forbidden errors for accept, accept-encoding, connection and user-agent, headers the HTTP client adds on its own. Browsers, proxies and load balancers add even more. Only forbid extra headers if you control every hop between the client and your app; otherwise leave the default (extra headers ignored).
API keys: a dependency that returns 401
from fastapi import Depends, HTTPException
def api_key(x_api_key: Annotated[str | None, Header()] = None):
if x_api_key != settings.api_key:
raise HTTPException(401, "invalid API key", headers={"WWW-Authenticate": "ApiKey"})
return x_api_key
@app.get("/secure", dependencies=[Depends(api_key)])
def secure():
return {"ok": True}
No key: 401 {"detail": "invalid API key"} with a WWW-Authenticate: ApiKey header. Correct key: {"ok": true}. Making the header optional in the dependency is what turns the 422 into a proper 401. In real code, compare secrets with secrets.compare_digest to avoid timing differences.
For OAuth2 bearer tokens, FastAPI’s OAuth2PasswordBearer and HTTPBearer read the Authorization header for you and also register the security scheme in OpenAPI.
Set response headers
Declare a Response parameter and write to its headers:
from fastapi import Response
@app.get("/set")
def set_(response: Response):
response.headers["X-Request-Id"] = "abc123"
response.headers["Cache-Control"] = "no-store"
return {"ok": True}
The response carries x-request-id: abc123 and cache-control: no-store. For headers on every response (request IDs, security headers), use middleware instead of repeating this in each route.
If a browser app on another origin needs to read a custom response header, it must be listed in expose_headers of CORSMiddleware; otherwise JavaScript can’t see it even though it arrived.
Quick reference
| Task | Code |
|---|---|
| Optional header | x: Annotated[str | None, Header()] = None |
| Required header | x: Annotated[str, Header()] |
| Header with underscores | Header(convert_underscores=False) |
| Repeated header | Annotated[list[str] | None, Header()] |
| Several headers | a Pydantic model with Header() (don’t forbid extras) |
| Auth | a dependency that raises HTTPException(401) |
| Response header | response: Response then response.headers[...] |
Headers are one small part of building an API that holds up in production. The FastAPI course builds an orders API with JWT roles, idempotency keys, signed webhooks and 51 tests; the first three lessons are free.
Want the production version of this?
A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests. It runs offline in about 60 seconds.
See ShipRAG or get the free RAG checklist first.