Skip to main content

Modern authentication for FastAPI

Project description

Belgie

Self-hosted, type-safe authentication for FastAPI that makes Google OAuth and secure session cookies work with almost zero glue code. Keep your data, skip per-user SaaS bills, and still get a polished developer experience.

Who this is for

  • FastAPI teams that want Google sign-in and protected routes today, not after weeks of wiring.
  • Product engineers who prefer first-class type hints and adapter-driven design over magic.
  • Startups that would rather own their user data and avoid per-MAU pricing from hosted identity vendors.

What it solves

  • End-to-end Google OAuth 2.0 flow with CSRF-safe state storage.
  • Sliding-window, signed session cookies (no JWT juggling required).
  • Drop-in FastAPI dependencies for auth.user, auth.session, and scoped access.
  • A thin SQLAlchemy adapter that works with your existing models.
  • Composable client methods so you can add logging, analytics, or audit trails in your own endpoints.

How it compares

  • fastapi-users: feature-rich but now in maintenance mode and optimized for password-plus-OAuth flows. Belgie focuses on OAuth + session UX, keeps the surface area small, and ships type-driven adapters out of the box.
  • Hosted identity (Auth0, Clerk, Supabase Auth): great UIs and more providers, but billed per Monthly Active User and hosted off your stack. Belgie is MIT-licensed, runs in your app, and never charges per user.

Features at a glance

  • Google OAuth plugin with app-owned signin route support and callback/signout endpoints.
  • Session manager with sliding expiry and secure cookie defaults (HttpOnly, SameSite, Secure).
  • Scope-aware dependency for route protection (Security(auth.user, scopes=[...])).
  • Modern Python (3.12+), full typing, and protocol-based models.
  • Utility helpers for custom workflows.

Installation

pip install belgie
# or with uv
uv add belgie

For SQLAlchemy adapter support:

pip install belgie[alchemy]
# or with uv
uv add belgie[alchemy]

Optional extras: belgie[mcp], belgie[oauth], belgie[oauth-client], or belgie[all].

Quick start

1) Define models

from datetime import UTC, datetime
from uuid import UUID, uuid4
from sqlalchemy import JSON, ForeignKey, Index, Text, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "users"
    id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
    email: Mapped[str] = mapped_column(Text, unique=True, index=True)
    name: Mapped[str | None] = mapped_column(Text, nullable=True)
    image: Mapped[str | None] = mapped_column(Text, nullable=True)
    email_verified_at: Mapped[datetime | None] = mapped_column(nullable=True)
    scopes: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
    created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC))
    updated_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC))


class Account(Base):
    __tablename__ = "accounts"
    __table_args__ = (
        UniqueConstraint("provider", "provider_account_id", name="uq_accounts_provider_provider_account_id"),
        Index("ix_accounts_user_id_provider", "user_id", "provider"),
    )
    id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
    user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
    provider: Mapped[str] = mapped_column(Text)
    provider_account_id: Mapped[str] = mapped_column(Text)
    access_token: Mapped[str | None] = mapped_column(Text, nullable=True)
    refresh_token: Mapped[str | None] = mapped_column(Text, nullable=True)
    expires_at: Mapped[datetime | None] = mapped_column(nullable=True)
    scope: Mapped[str | None] = mapped_column(Text, nullable=True)
    created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC))


class Session(Base):
    __tablename__ = "sessions"
    id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
    user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
    expires_at: Mapped[datetime] = mapped_column(index=True)
    created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC))


class OAuthState(Base):
    __tablename__ = "oauth_states"
    id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
    state: Mapped[str] = mapped_column(Text, unique=True, index=True)
    expires_at: Mapped[datetime] = mapped_column()
    created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC))

2) Configure Belgie

from collections.abc import AsyncGenerator

from sqlalchemy.engine import URL
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from belgie import Belgie, BelgieSettings
from belgie.alchemy import BelgieAdapter
from belgie.oauth.google import GoogleOAuth

settings = BelgieSettings(
    secret="your-secret-key",
    base_url="http://localhost:8000",
)

engine = create_async_engine(URL.create("sqlite+aiosqlite", database="./app.db"))
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)


async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with session_maker() as session:
        yield session

adapter = BelgieAdapter(
    user=User,
    account=Account,
    session=Session,
    oauth_state=OAuthState,
)

auth = Belgie(
    settings=settings,
    adapter=adapter,
    database=get_db,
)

google_oauth_plugin = auth.add_plugin(
    GoogleOAuth(
        client_id="your-google-client-id",
        client_secret="your-google-client-secret",
        scopes=["openid", "email", "profile"],
    ),
)

3) Add routes to FastAPI

from typing import Annotated

from fastapi import Depends, FastAPI, Security
from fastapi.responses import RedirectResponse
from belgie.oauth.google import GoogleOAuthClient

app = FastAPI()
app.include_router(auth.router)


@app.get("/")
async def home():
    return {"message": "Welcome! Visit /login/google to sign in"}


@app.get("/login/google")
async def login_google(
    google: Annotated[GoogleOAuthClient, Depends(google_oauth_plugin)],
    return_to: str | None = None,
):
    auth_url = await google.signin_url(return_to=return_to)
    return RedirectResponse(url=auth_url, status_code=302)


@app.get("/protected")
async def protected(user: User = Depends(auth.user)):
    return {"email": user.email}


@app.get("/profile")
async def profile(user: User = Security(auth.user, scopes=["profile"])):
    return {"name": user.name, "email": user.email}

Run it:

uvicorn main:app --reload

Visit http://localhost:8000/login/google to sign in.

Configuration shortcuts

  • Environment variables: BELGIE_SECRET, BELGIE_BASE_URL, BELGIE_GOOGLE_CLIENT_ID, BELGIE_GOOGLE_CLIENT_SECRET, BELGIE_GOOGLE_SCOPES (loaded automatically by BelgieSettings()).
  • Session tuning: SessionSettings(cookie_name, max_age, update_age) controls lifetime and sliding refresh.
  • Cookie hardening: CookieSettings(http_only, secure, same_site) for production-ready defaults.
  • Google callback URL is fixed to <BELGIE_BASE_URL>/auth/provider/google/callback.

Plugin API migration note

  • bind() has been removed from plugins.
  • Register plugins with callable config objects: auth.add_plugin(GoogleOAuth(...)).

Router endpoints

  • GET /login/google – app-owned route that starts OAuth flow via plugin dependency
  • GET /auth/provider/google/callback – plugin callback route
  • POST /auth/signout – clear session cookie and invalidate server session

Limitations today

  • Google is the only built-in provider; more providers and email/password are on the roadmap.
  • You manage your own database migrations and deployment (by design—no third-party control plane).
  • If you upgrade from email_verified, rename or backfill that column in your own app migration to email_verified_at.

Why teams pick Belgie

  • Keep control of data and infra while getting a batteries-included OAuth flow.
  • Minimal surface area: a single Auth instance exposes router + dependencies.
  • Modern typing and clear protocols reduce integration mistakes and make refactors safer.
  • MIT license, zero per-user costs.

Documentation and examples

Contributing

MIT licensed. Issues and PRs welcome.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

belgie-0.10.3.tar.gz (7.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

belgie-0.10.3-py3-none-any.whl (12.1 kB view details)

Uploaded Python 3

File details

Details for the file belgie-0.10.3.tar.gz.

File metadata

  • Download URL: belgie-0.10.3.tar.gz
  • Upload date:
  • Size: 7.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.10.9 {"installer":{"name":"uv","version":"0.10.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for belgie-0.10.3.tar.gz
Algorithm Hash digest
SHA256 d1401ee9f796d23abe9030c94f52a4ff90c529d2feaf2b6250e44f68e4ea5c4d
MD5 2cfa04309d074d258ad36251144d9eba
BLAKE2b-256 36e0885f0ae8390e78325cddf7ac66ed3c88d3d6987491e0f149e246845b169d

See more details on using hashes here.

File details

Details for the file belgie-0.10.3-py3-none-any.whl.

File metadata

  • Download URL: belgie-0.10.3-py3-none-any.whl
  • Upload date:
  • Size: 12.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.10.9 {"installer":{"name":"uv","version":"0.10.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for belgie-0.10.3-py3-none-any.whl
Algorithm Hash digest
SHA256 f274bb3a5d8b2a6d4e670e809281ffcd510dda7cb426400b5d9eaafa0b275ca7
MD5 bf2725911f5573d436974fc29062c390
BLAKE2b-256 9ff7ac473130395a8529839250cee5b248d97317eca6626e03fde20ce6b3884b

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page