Headers & Cookies
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on
2026-10-02. All examples use Annotated, run on Python 3.10 or newer and were executed with TestClient on
Python 3.13. Headers and cookies are the two request inputs that are not part of the URL or the body: they carry
metadata (content negotiation, correlation ids, API versions) and client state (a session id, a theme). FastAPI
declares both exactly like query parameters, by choosing a marker class — Header() or Cookie() — so
conversion, validation and documentation come for free. For the other input sources see
Query Parameters and
Request Body & Models.
Reading a Header with Header()
Declare a parameter whose default or Annotated metadata is Header(). Without the marker, a simple-typed
parameter would be read from the query string instead.
from typing import Annotated
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/books")
def list_books(
user_agent: Annotated[str | None, Header()] = None,
x_request_id: Annotated[str | None, Header()] = None,
) -> dict:
return {"user_agent": user_agent, "x_request_id": x_request_id}
c = TestClient(app)
print(c.get("/books", headers={"X-Request-ID": "abc-123", "User-Agent": "bookshelf-cli/1.0"}).json())
print(c.get("/books").json())
for p in c.get("/openapi.json").json()["paths"]["/books"]["get"]["parameters"]:
print(p["name"], p["in"], p["required"])
Source: FastAPI: Header Parameters
Output:
{'user_agent': 'bookshelf-cli/1.0', 'x_request_id': 'abc-123'}
{'user_agent': 'testclient', 'x_request_id': None}
user-agent header False
x-request-id header False
HTTP header names are case-insensitive, so X-Request-ID and x-request-id are the same header and you write the
parameter in ordinary snake_case. A parameter with = None is optional; with no default it is required and a
missing header produces a 422. OpenAPI documents the parameters with in: header and the hyphenated name.
The same call from a shell, against a running server (fastapi dev):
curl -s localhost:8000/books -H 'X-Request-ID: abc-123' -H 'X-Tag: fiction' -H 'X-Tag: classic' -b 'session_id=s-42'
{"x_request_id":"abc-123","x_tag":["fiction","classic"],"session_id":"s-42"}
Underscores, Hyphens and convert_underscores
Python identifiers cannot contain -, but most standard header names do (User-Agent, Accept-Language). By
default Header() therefore converts the underscores of the parameter name into hyphens, both to read the request
and to document the header. If you need to match a header whose real name contains underscores, set
convert_underscores=False; the name is then used verbatim.
from typing import Annotated
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/legacy")
def legacy(
strange_header: Annotated[str | None, Header(convert_underscores=False)] = None,
) -> dict:
return {"strange_header": strange_header}
@app.get("/modern")
def modern(x_token: Annotated[str | None, Header()] = None) -> dict:
return {"x_token": x_token}
c = TestClient(app)
print(c.get("/legacy", headers={"strange_header": "v"}).json())
print(c.get("/legacy", headers={"strange-header": "v"}).json())
print(c.get("/modern", headers={"x_token": "underscored"}).json())
print(c.get("/modern", headers={"x-token": "hyphen"}).json())
print(c.get("/openapi.json").json()["paths"]["/legacy"]["get"]["parameters"][0]["name"])
Output:
{'strange_header': 'v'}
{'strange_header': None}
{'x_token': None}
{'x_token': 'hyphen'}
strange_header
Two things follow from the output. First, with convert_underscores=False only the underscore spelling matches.
Second, with the default conversion the request header x_token (underscore) is not accepted for x_token: it is
ignored and the parameter keeps its default. Since FastAPI 0.136.3 (2026-05-23) a header whose name contains
underscores is no longer matched when convert_underscores=True; earlier versions would also match it. The reason
is the ambiguity between the two spellings: some proxies and servers treat X_Token and X-Token as the same
header while others drop or forward only one of them, which can let a value slip past a front-end that filters
X-Token. Reading hyphenated names only removes that ambiguity (the release notes do not state the motivation, so
treat this as the usual rationale, not an official statement). The official documentation also warns that some proxies
and servers disallow underscores in header names altogether, which is the main reason to avoid
convert_underscores=False in new APIs. When a client really sends underscore names, read them from the request
(see Reading Headers and Cookies from Request) and treat them as untrusted.
Duplicate Headers as Lists
HTTP allows the same header to appear several times. Declare the parameter as a list and FastAPI collects every occurrence.
from typing import Annotated
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/tags")
def tags(x_tag: Annotated[list[str] | None, Header()] = None) -> dict:
return {"x_tag": x_tag}
c = TestClient(app)
r = c.get("/tags", headers=[("X-Tag", "fiction"), ("X-Tag", "classic")])
print(r.json())
print(c.get("/tags", headers={"X-Tag": "a, b"}).json())
print(c.get("/tags").json())
Output:
{'x_tag': ['fiction', 'classic']}
{'x_tag': ['a, b']}
None
Only repeated header lines become list items. A single line with a comma-separated value stays one string
('a, b'), and FastAPI does not split it: if your clients send the combined form, split it yourself or accept a
str and parse it. A parameter declared as a plain str receives only the first value when the header is repeated ('a' in a run with X-Tag: a and X-Tag: b).
Validation and Documentation
Header() accepts the same constraint and documentation arguments as Query() (min_length, max_length,
pattern, description, alias, examples and so on), because both derive from the same parameter base class.
from typing import Annotated
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/version")
def version(
accept_version: Annotated[str, Header(pattern=r"^v\d+$", description="API version, e.g. v2")],
) -> dict:
return {"accept_version": accept_version}
c = TestClient(app)
print(c.get("/version").json())
print(c.get("/version", headers={"Accept-Version": "2"}).json()["detail"][0]["type"])
print(c.get("/version", headers={"Accept-Version": "v2"}).json())
print(c.get("/openapi.json").json()["paths"]["/version"]["get"]["parameters"][0]["schema"])
Source: FastAPI: Header Parameters
Output:
{'detail': [{'type': 'missing', 'loc': ['header', 'accept-version'], 'msg': 'Field required', 'input': None}]}
string_pattern_mismatch
{'accept_version': 'v2'}
{'type': 'string', 'pattern': '^v\\d+$', 'description': 'API version, e.g. v2', 'title': 'Accept-Version'}
The error loc is ['header', 'accept-version']: errors name the real, hyphenated header, not the Python
variable. Constraints are copied into the OpenAPI schema (pattern, description), so generated clients and the
interactive documentation (see OpenAPI & Interactive Docs)
show them. Headers shared by many endpoints — an API key, a correlation id — are usually declared once in a
dependency; see Dependency Injection and
Security Fundamentals for header-based API keys.
Reading a Cookie with Cookie()
Cookie() works the same way, but reads from the Cookie request header, which the framework parses into
individual name-value pairs. Cookie names are case-sensitive and are not converted: the parameter name is the
cookie name.
from typing import Annotated
from fastapi import Cookie, FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/me")
def me(session_id: Annotated[str | None, Cookie()] = None) -> dict:
return {"session_id": session_id}
@app.get("/required")
def required(theme: Annotated[str, Cookie()]) -> dict:
return {"theme": theme}
c = TestClient(app)
print(c.get("/me").json())
c.cookies.set("session_id", "s-42")
print(c.get("/me").json())
c.cookies.clear()
r = c.get("/required")
print(r.status_code, r.json())
print(c.get("/required", headers={"Cookie": "theme=dark; other=1"}).json())
print(c.get("/openapi.json").json()["paths"]["/required"]["get"]["parameters"])
Source: FastAPI: Cookie Parameters
Output:
{'session_id': None}
{'session_id': 's-42'}
422 {'detail': [{'type': 'missing', 'loc': ['cookie', 'theme'], 'msg': 'Field required', 'input': None}]}
{'theme': 'dark'}
[{'name': 'theme', 'in': 'cookie', 'required': True, 'schema': {'type': 'string', 'title': 'Theme'}}]
A required cookie that is absent yields a 422 with loc ['cookie', 'theme']; cookies you did not declare
(other=1) are ignored. The TestClient keeps a cookie jar (c.cookies) like a browser would, so sequences such as
log in, then call a protected endpoint, behave as expected in tests.
Remember that a cookie is client-controlled input just like a header: it can be missing, malformed or forged.
Never trust a plain session_id cookie as proof of identity; use a signed or server-side-checked value (see
Security Fundamentals). The interactive Swagger UI cannot send
cookies you type in, because browsers do not let page scripts set the Cookie header, so test cookie endpoints with
curl (-b), HTTPie or TestClient.
curl -s localhost:8000/me -b 'session_id=s-42'
http localhost:8000/me Cookie:session_id=s-42
Header Parameter Models
Since FastAPI 0.115.0 (2024-09-17) a group of related headers can be declared as one Pydantic model and the
parameter marked Header(). FastAPI reads each model field from the corresponding header, building the model from
the request. Underscore conversion applies per field, lists collect duplicates, and the model is reusable across
endpoints.
from typing import Annotated
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class CommonHeaders(BaseModel):
x_request_id: str | None = None
x_tag: list[str] = []
@app.get("/books")
def books(headers: Annotated[CommonHeaders, Header()]) -> CommonHeaders:
return headers
class RawHeaders(BaseModel):
x_odd_name: str | None = None
@app.get("/raw")
def raw(headers: Annotated[RawHeaders, Header(convert_underscores=False)]) -> RawHeaders:
return headers
c = TestClient(app)
print(c.get("/books", headers=[("X-Request-ID", "r1"), ("X-Tag", "a"), ("X-Tag", "b"), ("Accept", "*/*")]).json())
print(c.get("/raw", headers={"x_odd_name": "1"}).json())
print([p["name"] for p in c.get("/openapi.json").json()["paths"]["/books"]["get"]["parameters"]])
print([p["name"] for p in c.get("/openapi.json").json()["paths"]["/raw"]["get"]["parameters"]])
Source: FastAPI: Header Parameter Models
Output:
{'x_request_id': 'r1', 'x_tag': ['a', 'b']}
{'x_odd_name': '1'}
['x-request-id', 'x-tag']
['x_odd_name']
Headers that are not fields of the model (Accept here) are ignored by default. Header(convert_underscores=False)
on the parameter turns the conversion off for the whole model, as the last two lines show. The model gets one
documented OpenAPI header parameter per field.
Forbidding Extra Headers
Pydantic’s model configuration extra="forbid" rejects anything the model does not declare, with an
extra_forbidden error. This is easy to get wrong for headers, because clients and proxies always add headers of
their own.
from typing import Annotated
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class StrictHeaders(BaseModel):
model_config = {"extra": "forbid"}
x_request_id: str | None = None
@app.get("/strict")
def strict(headers: Annotated[StrictHeaders, Header()]) -> StrictHeaders:
return headers
c = TestClient(app)
c.headers.clear() # drop the test client's default headers
r = c.get("/strict", headers={"X-Request-ID": "r1"})
print(r.status_code, r.json())
Output:
422 {'detail': [{'type': 'extra_forbidden', 'loc': ['header', 'host'], 'msg': 'Extra inputs are not permitted', 'input': 'testserver'}]}
Even with every default header removed, the request is rejected because the HTTP client always sends Host. A
browser or a proxy adds Accept, User-Agent, Accept-Encoding, Connection and more. With a real client such a
model rejects ordinary requests unless it declares every one of them, so extra="forbid" is rarely the right tool
for headers; the official documentation itself calls it a special, uncommon case. If you do use it, list the
standard headers (as the documentation’s CommonHeaders example does with host) and expect to maintain the list.
Prefer a plain model that ignores unknown headers and validates only what you care about.
The model for cookies behaves differently in practice, because a browser only sends cookies the site itself set (plus those of the domain), so forbidding extras can act as a strict allow-list:
from typing import Annotated
from fastapi import Cookie, FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Prefs(BaseModel):
model_config = {"extra": "forbid"}
session_id: str
theme: str = "light"
tracker: str | None = None
@app.get("/prefs")
def prefs(cookies: Annotated[Prefs, Cookie()]) -> Prefs:
return cookies
c = TestClient(app)
print(c.get("/prefs", headers={"Cookie": "session_id=s1; theme=dark"}).json())
r = c.get("/prefs", headers={"Cookie": "session_id=s1; evil=1"})
print(r.status_code, r.json())
r = c.get("/prefs")
print(r.status_code, r.json())
Source: FastAPI: Cookie Parameter Models
Output:
{'session_id': 's1', 'theme': 'dark', 'tracker': None}
422 {'detail': [{'type': 'extra_forbidden', 'loc': ['cookie', 'evil'], 'msg': 'Extra inputs are not permitted', 'input': '1'}]}
422 {'detail': [{'type': 'missing', 'loc': ['cookie', 'session_id'], 'msg': 'Field required', 'input': {'theme': 'light'}}]}
Cookie models were added together with header and query models in 0.115.0. The unknown evil cookie is rejected with
loc ['cookie', 'evil']; the missing session_id fails with missing. Be careful with this setting on a site that
shares a domain with other applications, analytics scripts or browser extensions: any of them may add a cookie and
lock real users out with a 422. The documentation notes that the same technique applies to Query() (see
Query Parameters); Pydantic itself is covered in
Pydantic v2 Essentials.
Reading Headers and Cookies from Request
Declared parameters give you validation and documentation. When you need the raw view — every header, headers with
unusual names, repeated headers in their original order — take the Starlette Request instead; its headers
mapping is case-insensitive and cookies is a plain dict.
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/raw")
def raw(request: Request) -> dict:
return {
"all_x_headers": [(k, v) for k, v in request.headers.items() if k.startswith("x-")],
"x_tag": request.headers.getlist("x-tag"),
"raw_has_underscore": [k.decode() for k, _ in request.headers.raw if b"_" in k],
"cookies": request.cookies,
"ua": request.headers.get("user-agent"),
}
c = TestClient(app)
print(c.get("/raw", headers=[("X-Tag", "a"), ("X-Tag", "b"), ("x_odd", "1")], cookies={"sid": "1"}).json())
Output:
{'all_x_headers': [['x-tag', 'a'], ['x-tag', 'b']], 'x_tag': ['a', 'b'], 'raw_has_underscore': ['x_odd'], 'cookies': {'sid': '1'}, 'ua': 'testclient'}
Header names arrive lower-cased. request.headers.getlist() returns every value of a repeated header, and
request.headers.raw exposes the original byte pairs, which is where an underscore header such as x_odd is still
visible. The trade-off is that nothing in this approach is validated, converted or documented in OpenAPI: a
parameter read from Request does not appear in the schema. Prefer Header() and Cookie() for anything part of
your API contract, and use Request for infrastructure code such as logging, middleware or proxies (see
Request Object & Content-Type and
Middleware, CORS & Proxies).
Setting Response Headers and Cookies
The other direction uses a Response parameter: FastAPI gives you a temporary response, copies the headers and
cookies you set on it into the final response and still applies your return value and response_model. Returning a
response object directly works too, but then the data is not filtered or validated for you.
from fastapi import FastAPI, Response
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
app = FastAPI()
@app.post("/login")
def login(response: Response) -> dict:
response.set_cookie(
key="session_id", value="s-42", max_age=3600, path="/", httponly=True,
secure=True, samesite="lax",
)
response.headers["X-Rate-Limit-Remaining"] = "99"
return {"ok": True}
@app.post("/logout")
def logout() -> JSONResponse:
resp = JSONResponse({"ok": True})
resp.delete_cookie("session_id", path="/")
return resp
c = TestClient(app, base_url="https://testserver")
r = c.post("/login")
print(r.headers.get_list("set-cookie"), r.headers["x-rate-limit-remaining"])
r = c.post("/logout")
print(r.headers.get_list("set-cookie"))
Source: FastAPI: Response Cookies
Output:
['session_id=s-42; HttpOnly; Max-Age=3600; Path=/; SameSite=lax; Secure'] 99
['session_id=""; expires=Fri, 02 Oct 2026 08:14:52 GMT; Max-Age=0; Path=/; SameSite=lax']
The set_cookie parameters of Starlette’s Response are key, value, max_age, expires, path (default
/), domain, secure, httponly, samesite ("lax" by default; "strict", "none" or None) and
partitioned. delete_cookie takes the same identifying attributes (key, path, domain, secure, httponly,
samesite, partitioned): the cookie is only removed if path and domain match the ones used when it was set.
For a session cookie use httponly=True (not readable from JavaScript), secure=True (HTTPS only) and an explicit
samesite; browsers only accept samesite="none" together with secure=True. Custom response headers (X-Rate-Limit-Remaining above)
are set on response.headers in the same way. See Custom Responses,
Status Codes & Error Handling for HTTPException headers,
and FastAPI: Response Headers for the three ways to attach
headers.
Where Older Guides Are Outdated, and What Changed Recently
-
Default-value markers. Older guides write
user_agent: str | None = Header(default=None)orOptional[str] = Header(None). The current style isAnnotated[str | None, Header()] = None(0.95.0 and later). -
Only single parameters. Material from before 0.115.0 (2024-09-17) reads headers and cookies one parameter at a time; they can now be grouped in Pydantic models, with
extra="forbid"available. -
Underscore headers. Before 0.136.3 (2026-05-23),
Header()also matched header names containing underscores; they are now ignored whenconvert_underscores=True, see Underscores, Hyphens andconvert_underscores. -
Cookies in the interactive docs. Swagger UI never sends cookies typed into its form; examples in older tutorials that claim otherwise are wrong for browsers.
-
Sessions. A bare
session_idcookie is shown in many tutorials as authentication. Use signed or server-validated tokens instead (see Security Fundamentals).
Pitfalls
-
Forgetting
Header()orCookie()turns the parameter into a query parameter; the header is silently ignored. -
A header with underscores is not read under the default conversion (0.136.3 and later); send hyphens.
-
extra="forbid"on a header model rejectsHost,User-Agent,Acceptand the rest; it needs a complete list and usually causes more problems than it solves. -
A comma-separated header line is one string; only repeated header lines become a list.
-
Cookie values are client-controlled; do not use one as proof of identity.
-
To delete a cookie,
delete_cookiemust use the samepathanddomainasset_cookie. -
Setting cookies on a
Responseparameter is lost when you return your own response object; set them on the returned object instead.