FastAPI · lesson 5 of 19
Beginner
6Intermediate
10- 7 FastAPI Pydantic Validation: Field Constraints and Nested Models
- 8 FastAPI Validators and Serialization with Pydantic
- 9 FastAPI CRUD with SQLModel and SQLite
- 10 FastAPI Database Migrations with Alembic and SQLModel
- 11 FastAPI Authentication: Password Hashing, OAuth2 and JWT
- 12 FastAPI Roles, CORS and Settings from Environment Variables
- 13 FastAPI Dependency Injection: Depends, yield and Overrides
- 14 Testing FastAPI with pytest, TestClient and httpx
- 15 FastAPI with Async PostgreSQL: Pools, Transactions, and Real Errors
- 16 Sync vs Async Endpoints in FastAPI, Measured
Advanced
3Also part of FastAPI
6Also 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
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 withhttponly=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 setslaxby 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
- Make
/securereturn 401 with{"detail": "Invalid API key"}when the key isn’tsk_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.
- 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.
- Build
/visit, which counts a client’s visits in avisitscookie that lasts one hour. What happens if the client sendsvisits=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.