Skip to content

Sessions and API tokens

People use the web app with a session cookie, scripts use an API token, and both reach the same routes.

import hashlib
import hmac
import os
import secrets
from typing import Annotated, TypedDict

from fastapi import FastAPI, Response, Security
from pydantic import BaseModel

from fastapi_multiauth import (
    APIKeyCookieAuth,
    HTTPBearerAuth,
    MultiAuth,
    UnauthorizedError,
    hash_token,
)

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):
    name: str
    password: bytes


USERS: dict[str, User] = {
    "alice": {"name": "alice", "password": hash_password("wonderland")}
}
NO_PASSWORD = hash_password("")
TOKEN_HASHES = {hash_token("user_alice-script-token"): "alice"}


class Credentials(BaseModel):
    username: str
    password: str


async def validate_session(username: str) -> dict:
    if username not in USERS:
        raise UnauthorizedError()
    return {"name": username}


async def validate_api_token(token: str) -> dict:
    username = TOKEN_HASHES.get(hash_token(token))
    if username is None:
        raise UnauthorizedError()
    return {"name": username}


session = APIKeyCookieAuth(
    name="__Host-session", validator=validate_session, secret_key=SECRET_KEY
)
api_tokens = HTTPBearerAuth(validator=validate_api_token, prefix="user_")
auth = MultiAuth(api_tokens, session)

CurrentUser = Annotated[dict, Security(auth)]

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, credentials.username)
    return {"ok": True}


@app.get("/me")
async def me(user: CurrentUser):
    return user
  • The bearer source comes first, so API calls never touch cookie parsing.
  • Only the token's hash is stored.
  • Cookie-authenticated POST routes need CSRF protection; token-authenticated ones do not.