Skip to content

Two-Factor Authentication (TOTP)

zeython.mfa adds a second factor to the session login you already have (see Authentication): a user enrolls an authenticator app (Google Authenticator, 1Password, Authy, ...), confirms it with a live code, and from then on a correct password alone isn't enough to log in.

No new dependency — RFC 6238 TOTP only needs HMAC-SHA1 and base32, both already in the standard library.

Why this exists

A leaked or guessed password shouldn't be enough to take over an account. TOTP is the standard second factor every major provider offers: a 30-second, 6-digit code derived from a shared secret and the current time, generated by an app the user already has installed, no SMS or third-party service required.

Setup

Add the columns to your user model — the same convention Authenticatable uses for password_hash:

# app/Models/user.py
from sqlalchemy import JSON, Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from zeython import Authenticatable, MfaEnrollable, Model, required, email

class User(Model, Authenticatable, MfaEnrollable):
    __tablename__ = "users"
    __hidden__ = ("password_hash", "mfa_secret", "mfa_recovery_codes")
    __rules__ = {"name": [required()], "email": [required(), email()]}

    name: Mapped[str] = mapped_column(String(255))
    email: Mapped[str] = mapped_column(String(255), unique=True)
    password_hash: Mapped[str] = mapped_column(String(255))

    mfa_secret: Mapped[str | None] = mapped_column(String(64), nullable=True)
    mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
    mfa_recovery_codes: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
    mfa_last_counter: Mapped[int | None] = mapped_column(Integer, nullable=True)

mfa_secret and mfa_recovery_codes belong in __hidden__ — they're as sensitive as password_hash and must never round-trip through to_dict().

mfa_last_counter is optional, but worth adding: without it, a valid TOTP code stays usable for its entire ~90s validity window and can be replayed any number of times within it — an attacker who captures one in flight (a phishing proxy, a compromised logging middlebox, shoulder-surfing) can submit that same code themselves and get in too, even though the victim already used it. With the column present, confirm()/verify_and_consume() reject a code whose 30s step was already consumed. Omit it and nothing changes from before this existed — it's opt-in, add it (and a migration) whenever you're ready.

No service provider or middleware to register: unlike auth itself, MFA is just a set of functions operating on your user model and the existing session — AuthServiceProvider (for the session cookie and CSRF protection it needs) is the only prerequisite. A generated project already has all of this wired up — see app/Controllers/auth_controller.py.

Enrolling

Enrollment is two steps, both requiring the user to already be logged in: generate a secret, then confirm it with a live code from the app that just scanned it — proving the user actually finished setting it up before MFA starts gating their login.

# app/Controllers/auth_controller.py
from zeython.mfa import confirm, enroll

async def mfa_enroll(self, request):
    user = await current_user(request)
    if user is None:
        raise UnauthorizedException("Authentication required.")

    enrollment = await enroll(user, account_name=user.email)
    return JSONResponse({"secret": enrollment.secret, "uri": enrollment.uri})

async def mfa_confirm(self, request):
    user = await current_user(request)
    if user is None:
        raise UnauthorizedException("Authentication required.")

    data = await request.json()
    recovery_codes = await confirm(user, data.get("code", ""))
    return JSONResponse({"recovery_codes": recovery_codes})

enrollment.uri is an otpauth://totp/... URI — render it as a QR code client-side (any JS QR library) for the user to scan, or show enrollment.secret directly for manual entry. Nothing here pulls in an image-processing dependency; that choice is left to your frontend.

confirm() raises ValidationException (a 422) if the code doesn't verify or there's no enrollment in progress, turns mfa_enabled on, and returns 8 recovery codes in plaintext — the only time they're ever visible. Only their hash is stored, the same way a password is. Show them to the user once and tell them to save them somewhere safe; there's no way to retrieve them again short of disabling and re-enrolling.

Calling enroll() again before confirm() replaces the pending secret, so an abandoned enrollment can't be confirmed later with a stale one.

Gating login behind the second factor

Once mfa_enabled is true, a correct password moves the user into a pending state instead of logging them in outright:

# app/Controllers/auth_controller.py
from zeython.mfa import complete_challenge
from zeython.mfa import start_challenge as start_mfa_challenge

async def login(self, request):
    data = await request.json()
    user = await manager.attempt(data.get("email", ""), data.get("password", ""))
    if user is None:
        raise UnauthorizedException("Invalid email or password.")

    if user.mfa_enabled:
        start_mfa_challenge(request, user)
        return JSONResponse({"mfa_required": True})

    auth_login(request, user)
    return JSONResponse(user.to_dict())

async def mfa_challenge(self, request):
    data = await request.json()
    user = await complete_challenge(request, data.get("code", ""))
    if user is None:
        raise UnauthorizedException("Invalid or expired code.")
    return JSONResponse(user.to_dict())

start_challenge() stashes the user's id in the session under its own key — a different key than the one zeython.auth.login() uses — so current_user()/require_auth() keep returning nothing until complete_challenge() succeeds. A client that never finishes the second step never gets treated as logged in, even though the password check already passed.

complete_challenge() accepts either a live TOTP code or an unused recovery code, and on success calls zeython.auth.login() for you — no separate step needed. On failure it returns None and leaves the pending challenge in place, so the client can retry.

Rate-limited by default (rate_limit=True), keyed to the pending user, not the caller's IP — a 6-digit TOTP code has only ~1,000,000 live combinations, brute-forceable by an attacker who already has the victim's password and is only missing the second factor; keying by account rather than IP still catches guesses spread across many source IPs/proxies against the one account being targeted. This only takes effect if RateLimitServiceProvider is registered (it is by default in a generated project) — without it, complete_challenge() behaves exactly as if rate_limit=False, not a crash. Tune it or turn it off per call:

await complete_challenge(request, code, rate_limit_attempts=5, rate_limit_window=60.0)
await complete_challenge(request, code, rate_limit=False)  # apply your own throttle() instead

Recovery codes

Each of the 8 codes confirm() returns works exactly once — passing one to complete_challenge() (or verify_and_consume() directly) removes it from storage on success, so a captured or reused code can't be replayed, including by two requests presenting it at the same time: the check locks the user's row (Model.find(..., for_update=True), see Locking a row) so the second request's check blocks until the first's transaction actually commits, then correctly finds the code already gone. On SQLite specifically — no row-level locking there — this guarantee doesn't hold; two genuinely concurrent requests can still both succeed, the same as if the lock weren't there at all. They exist for the case an authenticator app is lost or a phone is wiped; without them, a locked-out user has no way back into their own account short of a manual database fix.

Disabling

from zeython.mfa import disable

async def mfa_disable(self, request):
    user = await current_user(request)
    if user is None:
        raise UnauthorizedException("Authentication required.")
    await disable(user)
    return JSONResponse({"message": "Two-factor authentication disabled."})

Clears the secret, the enabled flag, and every recovery code. A real app should require re-entering the password (or a fresh TOTP code) before calling this — an attacker who's already hijacked a session shouldn't be able to turn off the account's own second factor.

Clock drift

verify_totp() tolerates ±1 time step (30 seconds either side) by default, via its valid_window parameter — enough for ordinary clock drift between the server and the authenticator app's device without meaningfully widening the guessing window an attacker gets per code.

API reference

See zeython.mfa for the full function list — generate_secret(), provisioning_uri(), and verify_totp() if you need the TOTP primitives directly instead of the enroll/confirm/challenge flow above.