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.
- 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.
- 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.
- Hooks so you can plug in logging, analytics, or audit trails without forking.
- 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.
- 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.
- Event hooks and utility helpers for custom workflows.
pip install belgie
# or with uv
uv add belgieFor 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].
from datetime import UTC, datetime
from uuid import UUID, uuid4
from sqlalchemy import ForeignKey, String
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(String(255), unique=True, index=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
image: Mapped[str | None] = mapped_column(String(500), nullable=True)
email_verified: Mapped[bool] = mapped_column(default=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"
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(String(50))
provider_account_id: Mapped[str] = mapped_column(String(255))
access_token: Mapped[str | None] = mapped_column(String(1000), nullable=True)
refresh_token: Mapped[str | None] = mapped_column(String(1000), nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(nullable=True)
scope: Mapped[str | None] = mapped_column(String(500), 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"))
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(String(255), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(index=True)
created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC))from belgie import Belgie, BelgieSettings
from belgie.oauth.google import GoogleOAuthPlugin, GoogleOAuthSettings
from belgie.alchemy import AlchemyAdapter, SqliteSettings
settings = BelgieSettings(
secret="your-secret-key",
base_url="http://localhost:8000",
)
database = SqliteSettings(database="./app.db")
adapter = AlchemyAdapter(
user=User,
account=Account,
session=Session,
oauth_state=OAuthState,
database=database,
)
auth = Belgie(
settings=settings,
adapter=adapter,
)
google_oauth_plugin = auth.add_plugin(
GoogleOAuthPlugin,
GoogleOAuthSettings(
client_id="your-google-client-id",
client_secret="your-google-client-secret",
scopes=["openid", "email", "profile"],
),
)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 --reloadVisit http://localhost:8000/login/google to sign in.
- Environment variables:
BELGIE_SECRET,BELGIE_BASE_URL,BELGIE_GOOGLE_CLIENT_ID,BELGIE_GOOGLE_CLIENT_SECRET,BELGIE_GOOGLE_SCOPES(loaded automatically byBelgieSettings()). - 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.
bind()has been removed from plugins.- Plugin constructors now receive
BelgieSettingsand plugin settings:__init__(belgie_settings, settings).
GET /login/google– app-owned route that starts OAuth flow via plugin dependencyGET /auth/provider/google/callback– plugin callback routePOST /auth/signout– clear session cookie and invalidate server session
- 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).
- Keep control of data and infra while getting a batteries-included OAuth flow.
- Minimal surface area: a single
Authinstance exposes router + dependencies. - Modern typing and clear protocols reduce integration mistakes and make refactors safer.
- MIT license, zero per-user costs.
- docs/quickstart.md for full walkthrough
- examples/auth for a runnable app
MIT licensed. Issues and PRs welcome.