Two-Factor Authentication¶
A password proves something you know — and that's exactly what gets phished. Two-factor authentication (2FA) adds something you have: a rotating 6-digit code from an authenticator app (Google Authenticator, 1Password, Aegis…). arvel ships two layers:
TwoFactor— the low-level TOTP primitives (secret generation, theotpauth://provisioning URI, code verification, recovery-code generation). Use these directly if you want full control.- The high-level lifecycle (
enable_two_factor/confirm_two_factor/verify_two_factor/disable_two_factor/regenerate_recovery_codes) plus a login-challenge state machine — the batteries-included path this page mostly covers, following the standard enable → confirm → challenge flow.
Needs the [2fa] extra
2FA uses pyotp — uv add 'arvel[2fa]'.
Before you start: cast the columns¶
The lifecycle functions store three plain attributes on the user you pass in —
two_factor_secret, two_factor_recovery_codes (a list of hashed codes), and
two_factor_confirmed_at. arvel does not encrypt these for you: your
user model owns that by declaring the model casts (and a migration adding the three nullable
columns — arvel ships no migration for them, since they live on your user model, not a framework
table):
from arvel import Model
from arvel.auth import Authenticatable
class User(Model, Authenticatable):
__casts__ = {
"two_factor_secret": "encrypted",
"two_factor_recovery_codes": "encrypted:array",
"two_factor_confirmed_at": "datetime",
}
Without those casts the columns hold their values in plaintext — add them before shipping 2FA.
Enrolling a user¶
enable_two_factor generates a secret + a set of recovery codes, stores the secret and the codes'
hashes on the user, and returns both in plaintext once — show them to the user now:
from arvel.auth.two_factor import enable_two_factor
enrollment = await enable_two_factor(user)
enrollment.provisioning_uri # "otpauth://totp/arvel:[email protected]?secret=...&issuer=arvel"
enrollment.recovery_codes # ["a1b2c3d4e5", ...] — display ONCE, never persisted as plaintext
Enabling never auto-confirms — user.two_factor_confirmed_at stays unset. Ask the user to enter one
live code to prove they actually scanned it:
from arvel.auth.two_factor import confirm_two_factor
if not await confirm_two_factor(user, submitted_code):
return "Invalid code", 422
# user.two_factor_confirmed_at is now set — 2FA is enforced at login from here on
The login-challenge state machine¶
Once two_factor_confirmed_at is set, a normal password login must pause for the second factor
instead of establishing a full session. requires_two_factor_challenge tells you whether to pause;
begin_two_factor_challenge is the guard hook — call it instead of your usual login (e.g.
SessionGuard.login) when it does:
from arvel.auth.two_factor import begin_two_factor_challenge, requires_two_factor_challenge
async def login(request, user):
if requires_two_factor_challenge(user):
begin_two_factor_challenge(request, user) # stashes request.session["auth.2fa.pending"],
# raises TwoFactorRequired — unreachable below; catch it in your route to redirect
await SessionGuard().login(user, request) # only reached when 2FA isn't pending
Your app-side /two-factor-challenge route renders a code form and completes the challenge:
from arvel.auth.two_factor import complete_two_factor_challenge, pending_two_factor_user_id
async def verify_challenge(request, submitted_code):
user_id = pending_two_factor_user_id(request) # who's mid-challenge, from the session
if user_id is None:
return "No pending challenge", 400
user = await User.find(user_id)
if not await complete_two_factor_challenge(request, user, submitted_code):
return "Invalid code", 422 # pending flag is left intact — retry
await SessionGuard().login(user, request) # NOW establish the real session
return redirect("/")
arvel ships the state (the pending-session flag + the TwoFactorRequired signal) and the guard hook;
the /two-factor-challenge route itself — and wiring begin_two_factor_challenge into your actual
login handler — is app-side, same as any other route.
Verifying a code (TOTP or recovery)¶
verify_two_factor accepts either a live TOTP code or a recovery code, transparently. A matched
recovery code is consumed — removed from the stored set and re-persisted — so it can never be reused:
from arvel.auth.two_factor import verify_two_factor
await verify_two_factor(user, submitted_code) # True for a live TOTP code OR an unused recovery code
await verify_two_factor(user, same_recovery_code) # False the second time — single-use
Recovery codes¶
Phones get lost. Reissue a fresh set (invalidating the old one) with regenerate_recovery_codes:
from arvel.auth.two_factor import regenerate_recovery_codes
new_codes = await regenerate_recovery_codes(user) # plaintext, once — show them, then discard
Disabling 2FA¶
from arvel.auth.two_factor import disable_two_factor
await disable_two_factor(user) # clears the secret, the recovery codes, and confirmed_at
Lower-level: the TwoFactor primitives¶
Everything above is built on TwoFactor — reach for it directly if you need more control than the
lifecycle functions give you (a custom storage shape, a non-Model user, etc.):
from arvel.auth.two_factor import TwoFactor
secret = TwoFactor.generate_secret()
uri = TwoFactor.provisioning_uri(secret, account_name="[email protected]", issuer="Acme")
TwoFactor.verify(secret, submitted_code, valid_window=1) # ± 1 time-step of clock-drift tolerance
TwoFactor.current_code(secret) # the code valid right now (handy in tests)
codes = TwoFactor.recovery_codes(count=8) # fresh one-time codes to hash + store
Common mistakes & gotchas¶
- Storing the secret or recovery codes in plaintext. Declare the
encrypted/encrypted:arraycasts on your user model before callingenable_two_factor— arvel doesn't do this for you. - Recovery codes that work twice.
verify_two_factoralready burns them on match — don't build a parallel check that skips it. - No clock-drift tolerance. If users report "valid codes rejected," it's usually skew — keep
valid_window=1(the default); don't widen it far, since a larger window lengthens the guess surface. - Enabling 2FA before confirming a scan.
enable_two_factornever auto-confirms; always require one validconfirm_two_factorcall, or a fat-fingered setup locks the user out of their own account. - Skipping the login-challenge step. If your login handler calls
SessionGuard.logindirectly without checkingrequires_two_factor_challengefirst, a confirmed 2FA user logs in on password alone — the whole point of the second factor is defeated.
How it works¶
TwoFactor is a thin wrapper over pyotp (the [2fa] extra, imported lazily so it stays out of the
light core): generate_secret makes a base32 TOTP secret, provisioning_uri formats the standard
otpauth:// URI, verify checks a code within valid_window time-steps, recovery_codes returns
high-entropy one-time fallbacks. The lifecycle functions layer the higher-level semantics on top: recovery
codes are hashed individually (via the resolved Hasher, same as a password) so a leaked table
doesn't expose usable codes; the secret and the recovery-code list are otherwise stored exactly as you
set them — encryption-at-rest is your user model's encrypted/encrypted:array casts, not something
two_factor.py does itself. The challenge state machine is deliberately minimal: a session key
(auth.2fa.pending) holding the pending user id, and a TwoFactorRequired exception your login route
catches to redirect — no separate persisted "challenge" row.
See also¶
- Authentication — the password step 2FA layers on top of.
- Routes & Flows — wiring the enroll / confirm / challenge steps into real endpoints.
- Guards & Drivers — where the authenticated session is established.