Skip to main content
FastAPI · lesson 5 of 19
FastAPI beginner Lesson 5 of 19

Also part of: Networking

FastAPI Headers and Cookies

Read request headers with Header(), set response headers, and set, read and delete cookies in FastAPI, with real curl -i output and a curl cookie jar.

By SkillByExample · Published

fastapi python http-headers cookies api

Metadata Travels in Headers

Every HTTP request and response carries headers, Name: value lines before the body. They hold metadata: who the client is (User-Agent), what format the body is in (Content-Type), how long a response may be cached (Cache-Control), and credentials such as an API key. Header names are case-insensitive, so X-API-Key and x-api-key are the same header.

Cookies are built on headers. The server sends a Set-Cookie header, the client stores the value, and on later requests the client sends it back in a Cookie header. That’s how a browser stays logged in.

Outputs come from FastAPI 0.143.0 and Uvicorn 0.54.0 on Python 3.12, called with curl 7.85.0. Run each file with uvicorn <file>:app.

Reading Headers with Header()

Save as headers.py:

from fastapi import FastAPI, Header

app = FastAPI()


@app.get("/wrong")
def wrong(user_agent: str):
    return {"user_agent": user_agent}


@app.get("/client")
def client(
    user_agent: str = Header(),
    x_client_version: int | None = Header(default=None),
):
    return {"user_agent": user_agent, "client_version": x_client_version}


@app.get("/secure")
def secure(x_api_key: str = Header()):
    return {"key_ends_with": x_api_key[-4:]}

curl always sends a User-Agent header, yet:

$ curl -s http://127.0.0.1:8000/wrong
{"detail":[{"type":"missing","loc":["query","user_agent"],"msg":"Field required","input":null}]}

loc says query. Without Header(), a plain str argument is a query parameter, so FastAPI looked for ?user_agent= in the URL. /client declares its arguments properly:

$ curl -s http://127.0.0.1:8000/client
{"user_agent":"curl/7.85.0","client_version":null}
$ curl -s http://127.0.0.1:8000/client -H 'X-Client-Version: 7'
{"user_agent":"curl/7.85.0","client_version":7}
$ curl -s http://127.0.0.1:8000/client -H 'x-client-version: seven'
{"detail":[{"type":"int_parsing","loc":["header","x-client-version"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"seven"}]}

Python names can’t contain hyphens, so FastAPI converts the underscores in x_client_version to hyphens and matches X-Client-Version regardless of case. The type hint works as it does for query parameters: "7" became the integer 7, "seven" is a 422 with loc ["header", "x-client-version"], and int | None = Header(default=None) makes the header optional.

A header without a default is required:

$ curl -i http://127.0.0.1:8000/secure
HTTP/1.1 422 Unprocessable Entity
date: Tue, 13 Oct 2026 06:59:29 GMT
server: uvicorn
content-length: 96
content-type: application/json

{"detail":[{"type":"missing","loc":["header","x-api-key"],"msg":"Field required","input":null}]}
$ curl -s http://127.0.0.1:8000/secure -H 'X-API-Key: sk_live_9f3a'
{"key_ends_with":"9f3a"}

A missing API key gives 422 here, but for credentials, 401 Unauthorized is the conventional answer. Practice task 1 returns one. FastAPI’s security helpers, covered in Authentication: Password Hashing, OAuth2 and JWT, do this for you.

Setting Response Headers

Save as respond.py:

from fastapi import FastAPI, Response
from fastapi.responses import JSONResponse

app = FastAPI()


@app.get("/report")
def report(response: Response):
    response.headers["X-Report-Rows"] = "42"
    response.headers["Cache-Control"] = "max-age=60"
    return {"rows": 42}


@app.get("/legacy")
def legacy():
    return JSONResponse(
        content={"moved": True},
        headers={"Deprecation": "true", "Link": '</report>; rel="successor-version"'},
    )
$ curl -i http://127.0.0.1:8000/report
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 06:59:31 GMT
server: uvicorn
content-length: 11
content-type: application/json
x-report-rows: 42
cache-control: max-age=60

{"rows":42}
$ curl -i http://127.0.0.1:8000/legacy
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 06:59:31 GMT
server: uvicorn
deprecation: true
link: </report>; rel="successor-version"
content-length: 14
content-type: application/json

{"moved":true}

There are two ways to do it. With a response: Response argument, FastAPI hands you a response object to decorate, and you still return plain data; FastAPI copies your headers onto the real response. With JSONResponse, you build the response yourself and FastAPI sends it as-is. The second skips response_model filtering, so prefer the first unless you need full control.

You wrote X-Report-Rows, and it arrived as x-report-rows. Uvicorn sends header names in lowercase, which is allowed because names are case-insensitive. Header values must be strings, so it’s "42", not 42. Cache-Control: max-age=60 tells clients and proxies they may reuse this response for 60 seconds.

Cookies: Set, Read, Delete

A toy login that stores the username in a cookie (never do this for real; see the end of this section). Save as cookies.py:

from fastapi import Cookie, FastAPI, HTTPException, Response

app = FastAPI()


@app.post("/login")
def login(username: str, response: Response):
    response.set_cookie(key="session", value=f"token-for-{username}", httponly=True)
    return {"logged_in": username}


@app.get("/me")
def me(session: str | None = Cookie(default=None)):
    if session is None:
        raise HTTPException(status_code=401, detail="Not logged in")
    return {"session": session}


@app.post("/logout")
def logout(response: Response):
    response.delete_cookie(key="session")
    return {"logged_in": None}

curl doesn’t remember cookies unless you give it a cookie jar file: -c jar.txt saves cookies the server sets, and -b jar.txt sends them back.

$ curl -i http://127.0.0.1:8000/me
HTTP/1.1 401 Unauthorized
date: Tue, 13 Oct 2026 06:59:32 GMT
server: uvicorn
content-length: 26
content-type: application/json

{"detail":"Not logged in"}
$ curl -i -X POST 'http://127.0.0.1:8000/login?username=ada' -c jar.txt
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 06:59:32 GMT
server: uvicorn
content-length: 19
content-type: application/json
set-cookie: session=token-for-ada; HttpOnly; Path=/; SameSite=lax

{"logged_in":"ada"}
$ curl -s http://127.0.0.1:8000/me -b jar.txt
{"session":"token-for-ada"}

401 Unauthorized means “you need to identify yourself first”. After login, the set-cookie header carries the value plus attributes:

  • HttpOnly: browser JavaScript can’t read the cookie, which limits damage from injected scripts. You asked for it with httponly=True.
  • Path=/: the cookie is sent for every path on this site.
  • SameSite=lax: browsers don’t send it on most requests that other sites trigger, a defence against cross-site request forgery. Starlette sets lax by default.

No Max-Age or Expires means it’s a session cookie: a browser drops it when it closes. Here is what curl stored:

$ cat jar.txt
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

#HttpOnly_127.0.0.1	FALSE	/	FALSE	0	session	token-for-ada

The 0 is the expiry time: none. Logging out sends the cookie again, already expired:

$ curl -i -X POST http://127.0.0.1:8000/logout -b jar.txt -c jar.txt
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 06:59:32 GMT
server: uvicorn
content-length: 18
content-type: application/json
set-cookie: session=""; expires=Tue, 13 Oct 2026 06:59:32 GMT; Max-Age=0; Path=/; SameSite=lax

{"logged_in":null}
$ curl -i http://127.0.0.1:8000/me -b jar.txt
HTTP/1.1 401 Unauthorized
date: Tue, 13 Oct 2026 06:59:32 GMT
server: uvicorn
content-length: 26
content-type: application/json

{"detail":"Not logged in"}

There is no “delete cookie” message in HTTP. delete_cookie sets an empty value with Max-Age=0 and an expiry of right now, and the client throws it away. curl removed it from the jar, so the next /me is 401 again.

Now the reason this login is a toy:

$ curl -s http://127.0.0.1:8000/me -b 'session=forged'
{"session":"forged"}

The client controls every cookie it sends. -b 'session=forged' was accepted because the server trusted the value. A real session cookie holds a random id that the server looks up, or a signed token whose signature the server checks. Authentication: Password Hashing, OAuth2 and JWT builds the signed-token version.

Practice

  1. Make /secure return 401 with {"detail": "Invalid API key"} when the key isn’t sk_live_9f3a.
Solution
@app.get("/secure")
def secure(x_api_key: str = Header()):
    if x_api_key != "sk_live_9f3a":
        raise HTTPException(status_code=401, detail="Invalid API key")
    return {"ok": True}
$ curl -i http://127.0.0.1:8000/secure -H 'X-API-Key: guess'
HTTP/1.1 401 Unauthorized
date: Tue, 13 Oct 2026 06:59:46 GMT
server: uvicorn
content-length: 28
content-type: application/json

{"detail":"Invalid API key"}
$ curl -s http://127.0.0.1:8000/secure -H 'X-API-Key: sk_live_9f3a'
{"ok":true}

Import HTTPException from fastapi. A missing header is still a 422; only a wrong key reaches your check.

  1. Accept a header that can appear more than once, X-Tag, as a list. Send it twice, then once with a comma-separated value.
Solution
@app.get("/tags")
def tags(x_tag: list[str] = Header(default=[])):
    return {"tags": x_tag}
$ curl -s http://127.0.0.1:8000/tags -H 'X-Tag: red' -H 'X-Tag: blue'
{"tags":["red","blue"]}
$ curl -s http://127.0.0.1:8000/tags -H 'X-Tag: red, blue'
{"tags":["red, blue"]}

Each repeated header becomes one list item. FastAPI does not split a single header on commas, so red, blue stays one string.

  1. Build /visit, which counts a client’s visits in a visits cookie that lasts one hour. What happens if the client sends visits=lots?
Solution
@app.get("/visit")
def visit(response: Response, visits: int = Cookie(default=0)):
    response.set_cookie(key="visits", value=str(visits + 1), max_age=3600)
    return {"visits": visits + 1}
$ curl -i http://127.0.0.1:8000/visit -c v.txt
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 06:59:46 GMT
server: uvicorn
content-length: 12
content-type: application/json
set-cookie: visits=1; Max-Age=3600; Path=/; SameSite=lax

{"visits":1}
$ curl -s http://127.0.0.1:8000/visit -b v.txt -c v.txt
{"visits":2}
$ curl -s http://127.0.0.1:8000/visit -b v.txt -c v.txt
{"visits":3}
$ curl -s http://127.0.0.1:8000/visit -b 'visits=lots'
{"detail":[{"type":"int_parsing","loc":["cookie","visits"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"lots"}]}

max_age=3600 adds Max-Age=3600, so the cookie outlives a browser restart for an hour. Cookies are validated like any other parameter: visits: int turns "2" into 2 and rejects lots with loc ["cookie", "visits"]. Passing -b v.txt -c v.txt together reads the jar and writes the updated cookie back.

Frequently Asked Questions

How do I read a request header in FastAPI?
Declare a function argument with Header() as its default, for example user_agent: str = Header(). FastAPI converts the underscores in the argument name to hyphens and matches the header case-insensitively, so user_agent reads the User-Agent header. Without Header(), FastAPI treats the argument as a query parameter instead.
Why does my FastAPI header parameter return 'Field required' with loc query?
You declared the argument without Header(), as in user_agent: str. FastAPI then looks for a query parameter called user_agent, and since the URL has none it returns a 422 with loc ['query', 'user_agent']. Change the default to Header() and it reads the header.
How do I set a response header in FastAPI?
Add a response: Response argument to the route and assign to response.headers, for example response.headers['Cache-Control'] = 'max-age=60', then return your data as usual. Alternatively return a JSONResponse with a headers dict. Header names are sent in lowercase by Uvicorn, which is fine because HTTP header names are case-insensitive.
How do I set and read cookies in FastAPI?
Set one with response.set_cookie(key='session', value='...', httponly=True) on a response: Response argument. Read one with an argument such as session: str | None = Cookie(default=None). Delete one with response.delete_cookie('session'), which sends the cookie again with Max-Age=0 so the client drops it.
Can a client fake a cookie in FastAPI?
Yes. A cookie is just a header the client sends, so any client can send any value: curl -b 'session=forged' works. Never trust a cookie's value by itself. Store a random session id that you look up on the server, or a signed token such as a JWT whose signature you verify.