Skip to content

Combining sources

MultiAuth

MultiAuth puts several sources on one route: browsers send a cookie, scripts send a token, both reach the same endpoints.

import os

from fastapi import FastAPI, Security

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

SECRET_KEY = os.environ["SECRET_KEY"]
TOKENS = {hash_token("user_alice-token"): "alice"}


async def validate_token(token: str) -> str:
    username = TOKENS.get(hash_token(token))
    if username is None:
        raise UnauthorizedError()
    return username


async def validate_session(username: str) -> str:
    return username


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

app = FastAPI()


@app.get("/me")
async def me(username: str = Security(auth)):
    return {"username": username}

Sources are tried in order, and the first one that finds a credential decides:

  • If that credential is invalid, the request is refused. It does not fall through to the next source.
  • With no credential at all, the 401 lists every source's challenge.
  • All sources appear in /docs.

Put the bearer source first: when a request carries both a token and a cookie, the token the client sent on purpose wins over the cookie the browser attached on its own.

Optional authentication

optional() returns a copy that gives None instead of a 401 when the request has no credential. Use it for pages that work for everyone but show more to signed-in users:

from typing import Annotated

from fastapi import FastAPI, Security

from fastapi_multiauth import HTTPBearerAuth, UnauthorizedError, hash_token

EDITORS = {hash_token("editor-token"): "alice"}


async def validate_token(token: str) -> str:
    editor = EDITORS.get(hash_token(token))
    if editor is None:
        raise UnauthorizedError()
    return editor


bearer = HTTPBearerAuth(validator=validate_token)
MaybeEditor = Annotated[str | None, Security(bearer.optional())]

app = FastAPI()


@app.get("/articles/{slug}")
async def article(slug: str, editor: MaybeEditor):
    return {"slug": slug, "show_drafts": editor is not None}

A credential that is present but invalid still gets a 401: optional() never turns a bad token into an anonymous visitor. It works on single sources and on MultiAuth.

Warning

optional() cannot be combined with scopes or step-up guards: an anonymous visitor can never satisfy them.