Blog/fastapi

FastAPI headers: read, validate and set them (and the underscore trap)

Read request headers in FastAPI with Header(), handle repeated headers, group them in a Pydantic model, require API keys and set response headers. All tested.

In this post

What this post covers
  1. Read one header
  2. The underscore trap
  3. Required headers
  4. Repeated headers
  5. Group headers in a Pydantic model
  6. API keys: a dependency that returns 401
  7. Set response headers
  8. Quick reference
How this was testedhardware, versions, data

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.

Learn it step by step · free

Go deeper in the RecallRun courses

All 11 free courses →

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.

Written and measured by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries