Cookie sessions¶
APIKeyCookieAuth reads a cookie. With a secret_key, the cookie is signed and expires on its own: a session without a session table.
import hashlib
import hmac
import os
import secrets
from typing import TypedDict
from fastapi import FastAPI, Response, Security
from pydantic import BaseModel
from fastapi_multiauth import APIKeyCookieAuth, UnauthorizedError
SECRET_KEY = os.environ["SECRET_KEY"]
def hash_password(password: str, salt: bytes | None = None) -> bytes:
salt = salt or secrets.token_bytes(16)
return salt + hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)
def check_password(password: str, stored: bytes) -> bool:
return hmac.compare_digest(hash_password(password, stored[:16]), stored)
class User(TypedDict):
id: str
name: str
password: bytes
USERS: dict[str, User] = {
"alice": {"id": "1", "name": "alice", "password": hash_password("wonderland")}
}
NO_PASSWORD = hash_password("")
async def validate_session(user_id: str) -> dict:
for user in USERS.values():
if user["id"] == user_id:
return {"id": user["id"], "name": user["name"]}
raise UnauthorizedError()
class Credentials(BaseModel):
username: str
password: str
session = APIKeyCookieAuth(
name="__Host-session", validator=validate_session, secret_key=SECRET_KEY
)
app = FastAPI()
@app.post("/login")
async def login(credentials: Credentials, response: Response):
user = USERS.get(credentials.username)
stored = user["password"] if user else NO_PASSWORD
if not check_password(credentials.password, stored) or user is None:
raise UnauthorizedError()
session.set_cookie(response, user["id"])
return {"ok": True}
@app.post("/logout")
async def logout(response: Response):
session.delete_cookie(response)
return {"ok": True}
@app.get("/me")
async def me(user: dict = Security(session)):
return user
set_cookie signs the value and adds a timestamp. Each request checks the signature and age before your validator runs, so it only sees the value you set.
Passwords are stored as salted scrypt hashes and compared with hmac.compare_digest. An unknown username is checked against NO_PASSWORD, so it takes as long to refuse as a wrong password.
delete_cookie removes the cookie from the browser, but a copy of it stays valid until ttl runs out. To end a session on the server, use revocable sessions.
Options¶
| Argument | Default | |
|---|---|---|
secret_key | None | At least 32 bytes. Without it the cookie is not signed. |
ttl | 86400 | Lifetime in seconds. |
secure | True | Turn off only for local HTTP development. |
samesite | "lax" | "strict" or "none". |
session_id | False | See revocable sessions. |
csrf | False | Refuse requests from other origins. See CSRF. |
trusted_origins | () | Other origins csrf lets through. |
Signed is not encrypted
Anyone holding the cookie can read its value. Store an id in it, never a secret.
Name the cookie __Host-...
A __Host- cookie cannot be overwritten from a subdomain. The defaults already satisfy its rules.
CSRF¶
Browsers send cookies with every request to your site, whoever triggered it. samesite="lax" blocks most cross-site requests but not all of them. Set csrf=True to refuse the rest:
import os
from fastapi import FastAPI, Security
from pydantic import BaseModel
from fastapi_multiauth import APIKeyCookieAuth, UnauthorizedError
SECRET_KEY = os.environ["SECRET_KEY"]
USERS = {"1": {"id": "1", "email": "alice@example.com"}}
class Email(BaseModel):
email: str
async def validate_session(user_id: str) -> dict:
if user_id not in USERS:
raise UnauthorizedError()
return USERS[user_id]
session = APIKeyCookieAuth(
name="__Host-session",
validator=validate_session,
secret_key=SECRET_KEY,
csrf=True,
trusted_origins=["https://app.example.com"],
)
app = FastAPI()
@app.post("/me/email")
async def change_email(body: Email, user: dict = Security(session)):
user["email"] = body.email
return user
Every request except GET, HEAD and OPTIONS must now come from your own origin, or from one listed in trusted_origins, such as a frontend on its own domain. Anything else gets a 403 before your validator runs. A sibling subdomain counts as another origin.
The check reads Sec-Fetch-Site, which browsers set and pages cannot forge, and falls back to comparing Origin with the Host header for older browsers. Behind a proxy, keep the Host header intact or list your public origin in trusted_origins.
Scripts and tests
A request carrying neither header is refused too. Every browser released since 2020 sends one, so only non-browser clients are affected: send Sec-Fetch-Site: same-origin from your tests, and give scripts a bearer token instead of a cookie.
GET routes are never checked, so keep them free of side effects.
Key rotation¶
Pass a list of keys: the first one signs, all of them verify. Add the new key in front, then remove the old one once a ttl has passed.
Authentication level¶
set_cookie(response, value, acr=..., amr=...) signs the login's level and methods into the cookie, for stepped_up(). Cookies set by earlier releases keep working until they expire; they carry no level, so they never pass stepped_up().
Revocable sessions¶
A signed cookie stays valid until it expires; delete_cookie only clears one browser. To end a session for real, set session_id=True: each set_cookie creates a random id, returns it, and passes it to your validator.
import os
from fastapi import FastAPI, Request, Response, Security
from fastapi_multiauth import APIKeyCookieAuth, UnauthorizedError
SECRET_KEY = os.environ["SECRET_KEY"]
REVOKED: set[str] = set()
async def validate_session(user_id: str, *, session_id: str) -> dict:
if session_id in REVOKED:
raise UnauthorizedError()
return {"id": user_id}
session = APIKeyCookieAuth(
name="__Host-session",
validator=validate_session,
secret_key=SECRET_KEY,
session_id=True,
)
app = FastAPI()
def start_session(response: Response, user_id: str) -> str | None:
return session.set_cookie(response, user_id)
@app.post("/logout")
async def logout(request: Request, response: Response):
session_id = session.delete_cookie(response, request)
if session_id:
REVOKED.add(session_id)
return {"ok": True}
@app.get("/me")
async def me(user: dict = Security(session)):
return user
Call start_session from your login route once the password matches, as in the first example. Every set_cookie creates a new id, including a re-issue after step-up, so move a stored session to the id it returns. You decide where the ids live and what "revoked" means. session_id_of(request) returns the current id; the Sign out everywhere recipe builds a device list with it.