Skip to content

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.