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.

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"])

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"])

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.

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"])

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"]])

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())

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"))

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) or Optional[str] = Header(None). The current style is Annotated[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 when convert_underscores=True, see Underscores, Hyphens and convert_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_id cookie is shown in many tutorials as authentication. Use signed or server-validated tokens instead (see Security Fundamentals).

Pitfalls

  • Forgetting Header() or Cookie() 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 rejects Host, User-Agent, Accept and 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_cookie must use the same path and domain as set_cookie.

  • Setting cookies on a Response parameter is lost when you return your own response object; set them on the returned object instead.