← blog

Better Auth for Python: Authentication Made Boring (On Purpose)

Kernia is Better Auth for Python: a type-safe, plugin-based, batteries-included auth library for FastAPI, Starlette, and Django. Here's what's in it.

· Themba

We rewrote our auth code more times than I want to admit. Hand-rolled sessions, then django-allauth, then a pile of OAuth glue, then a half-built passkey flow we never finished. Every rewrite started the same way: "this time we'll do it properly." Then a new requirement landed (SSO, API keys, seat-based billing) and we were back in the auth layer, moving cookies around at 11pm.

So we built Kernia: Better Auth for Python. It is a faithful port of Better Auth, the TypeScript auth library, rebuilt as a Python-native package family for FastAPI, Starlette, and Django. The goal was not to be clever. It was to make auth boring enough that we never have to think about it again.

This post is the walkthrough: what Kernia is, the design decisions, and the actual code to stand up a login flow. Every snippet below is real and runs against the current library.

Why "Better Auth for Python" is the right frame

Python has auth libraries. What it did not have was one that treats authentication the way Better Auth does on the JavaScript side: framework-agnostic, type-safe, and built from composable plugins rather than a single monolith you bend to fit through configuration.

Kernia is an independent implementation that is wire-compatible with Better Auth 1.6.11. That phrase does real work, so here is what it means in practice. Kernia keeps the same HTTP routes, the same cookie model, and the same camelCase JSON payloads as Better Auth. The payoff is on the frontend:

auth-client.ts
import { createAuthClient } from "better-auth/client";

// This is the official Better Auth JS client, unchanged.
// It is talking to a Python server.
export const authClient = createAuthClient({ baseURL: "/api/auth" });

Your React, Vue, or Svelte frontend points the official Better Auth client at a Kernia backend and just works. You do not write a shim or maintain a second client SDK. The repo proves this on every change with a headless wire-check that drives the real Better Auth client through sign-up, session, sign-out, sign-in, and organization create/list against the example server.

If you already use Better Auth on a Node backend and are moving the API to Python, this is the part that matters: the browser cannot tell the difference. The migration is server-side only.

The shape of a Kernia app

Authentication in Kernia is a set of plugins you compose, not a framework you inherit from. You call init with options, pass the plugins you want, and you get an auth object back. Here is a minimal FastAPI setup with email/password.

auth.py
import os

from kernia import KerniaOptions
from kernia.auth import init
from kernia.plugins import email_and_password
from kernia_sqlalchemy import sqlalchemy_adapter

auth = init(KerniaOptions(
    database=await sqlalchemy_adapter(url="postgresql+asyncpg://localhost/app"),
    secret=os.environ["KERNIA_SECRET"],
    base_url="https://app.example.com",
    plugins=[email_and_password()],
))

Three things to notice. The database is an adapter, not a hard dependency on one ORM. The secret is yours and signs the cookies. The plugins list is where every feature lives, including email/password itself.

Mounting it on FastAPI is two lines. mount_kernia serves the whole auth surface under /api/auth/*, and require_session is a dependency that protects your own routes.

main.py
from fastapi import Depends, FastAPI
from kernia_fastapi import mount_kernia, require_session
from kernia.types.context import Session

app = FastAPI()
mount_kernia(app, auth)                      # serves /api/auth/*

@app.get("/me")
async def me(session: Session = Depends(require_session)):
    return {"user_id": session.user_id}

That is the entire integration. require_session reads the signed cookie, loads the session, and 401s if there isn't one. session.user_id is typed. There is no middleware to register in a specific order, and no request-local globals to thread through.

The login flow, end to end

This is a real sign-up and sign-in, with the real routes and the real error codes. The calls below are taken from the library's own end-to-end tests, so they are exactly what the server does.

A sign-up posts an email and password. The default is to auto sign-in, so the response sets a signed session cookie immediately.

POST /api/auth/sign-up/email
{ "email": "alice@example.com", "password": "correcthorse" }

The response carries the user and a session, and a better-auth.session_token cookie comes back on the Set-Cookie header. That cookie then attaches a session to every subsequent call, including your own /me route above.

Sign-in and sign-out are what you'd expect:

POST /api/auth/sign-in/email
{ "email": "alice@example.com", "password": "correcthorse" }

POST /api/auth/sign-out

GET  /api/auth/get-session

What I care about more than the happy path is how it fails, because that is where hand-rolled auth leaks information. Kernia returns honest, specific codes:

A wrong password returns 401 INVALID_CREDENTIALS. Signing up with an email that already exists returns 409 EMAIL_ALREADY_IN_USE, and a password under the minimum length returns 400 PASSWORD_TOO_SHORT. Even an unknown route returns 404 NOT_FOUND with a machine-readable code rather than an HTML stack trace.

Password reset is a real round-trip, not a stub: forget-password issues a token, reset-password consumes it, the old password stops working and the new one starts. The library tests that full sequence on every adapter.

The crypto, because this is where you don't want surprises

Password hashing is the one part of auth you should never write yourself, and it is the part boilerplate gets wrong most often. Kernia uses Argon2id, the OWASP-recommended modern KDF, via argon2-cffi. Hashes are stored as the standard PHC string, e.g. $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>.

The detail worth stealing even if you never use Kernia is the upgrade path. Older systems often have scrypt or weaker hashes already in the database. Kernia keeps a scrypt verifier so existing hashes still authenticate, and it migrates them transparently on the next successful login:

from kernia.crypto import verify_password, needs_rehash, hash_password

if verify_password(password, row.password):
    if needs_rehash(row.password):
        row.password = hash_password(password)   # re-hash to argon2id
        await adapter.update(...)                 # save the upgraded hash

needs_rehash returns True for any legacy scrypt hash and for argon2id hashes whose parameters have since been raised. So a user with an old hash logs in once and silently moves to argon2id, with no forced password reset and no migration script. That is the kind of thing you only get right when you've been burned by getting it wrong.

Hashing is not the whole security story. Out of the box Kernia ships HMAC-SHA256 signed cookies, trusted-origins CSRF protection on by default, PKCE-bound OAuth state, AES-GCM encryption of OAuth tokens at rest, secret rotation, rate limiting, and a real WebAuthn verifier for passkeys. None of that is opt-in homework. It is the default.

Plugins are the whole point

Every feature past email/password is a plugin, and each plugin owns its own routes, database tables, rate-limit rules, and error codes. You add capability by adding a constructor to the list. Need multi-tenant organizations with teams and invitations? Add organization().

auth.py
from kernia import KerniaOptions
from kernia.auth import init
from kernia.plugins import email_and_password
from kernia.plugins.organization import organization

async def send_invitation(invitation: dict) -> None:
    await email_client.send(
        to=invitation["email"],
        subject="You've been invited to Acme",
        html=f'Accept: https://app.example.com/accept/{invitation["id"]}',
    )

auth = init(KerniaOptions(
    database=adapter,
    secret=os.environ["KERNIA_SECRET"],
    base_url=os.environ["KERNIA_BASE_URL"],
    plugins=[
        email_and_password(),
        organization(teams=True, send_invitation=send_invitation),
    ],
))

That one line adds organization, member, and invitation tables, the routes to manage them, and an activeOrganizationId on the session so the rest of your app can scope to the current org. The same pattern holds for the rest of the catalog. Passkeys are passkey(rp_id=..., rp_name=..., origin=...). Stripe seat-based billing, SSO via SAML and OIDC, SCIM provisioning, API keys, two-factor, magic links, and an OpenAPI generator that emits a validated spec at /api/auth/openapi.json are all plugins or standalone packages you pull in the same way.

The count today is 27 built-in plugins plus 7 standalone packages, with 35 social providers. You will not need most of them. The point is that when a requirement lands six months from now, you add a constructor instead of starting another rewrite.

What "boring" actually means here

"Boring" is easy to say and hard to earn. For Kernia it has a specific, checkable definition: an area is done when the upstream Better Auth test cases for it are ported and passing, not when it looks plausible.

A parity gate (scripts/audit_layout.py) fetches the pinned Better Auth source and fails the build if any upstream directory lacks a Kernia counterpart or a documented waiver. The adapter layer runs a 64-case conformance suite against memory, SQLAlchemy (Postgres, MySQL, SQLite), and MongoDB, so a query that works on SQLite works the same on Postgres. The wire-check drives the genuine Better Auth JS client against the example server. That discipline is the product. It is why we trust it enough to put it under our own apps and stop touching it.

Try it

Kernia is open source under MIT. While parity work stabilizes it installs from source:

git clone https://github.com/advantch/kernia
cd kernia
uv sync
uv pip install -e packages/core -e packages/sqlalchemy_adapter -e packages/fastapi_integration

There is a CLI to scaffold an app (kernia init --adapter sqlite --framework fastapi), and a full FastAPI-plus-React SaaS reference app in examples/. Start with Installation and Basic Usage, then read the plugin docs for the feature you need. The code lives at github.com/advantch/kernia.

If you have rewritten auth more than once, you already know why this exists.


Themba Mahlangu builds and writes about AI-native software. Founder of Advantch (this studio), HyperFX (agentic marketing SaaS), Vanty.ai (data APIs for agents). Open-sources what his team builds on at github.com/advantch.

Ready to ship something? Book a 30-min Discovery call - we will scope your project and tell you yes/no within a week. Book here.

Want to learn this yourself? Join The AI Lab - $99/mo, three live events a week, two paths (operator and builder) in every lesson. Join here.