Authentication¶
Authentication answers one question: is this request really who it claims to be? The classic answer is a password — the user hands you an email and a secret, you check the secret against a hash you stored, and if it matches you remember them for the rest of the session. This page covers exactly that: making your user model authenticatable, verifying credentials, and managing the logged-in session. Logging in through Google or Keycloak instead of a password is the same idea with a different proof — that's single sign-on; the session half below is identical.
Make your user model authenticatable¶
Mix Authenticatable into your User model. It adds the small contract the auth system needs — an
identifier and (for password login) a hashed credential:
from arvel import Model
from arvel.auth import Authenticatable
class User(Model, Authenticatable):
__fields__ = {"email": str, "name": str, "password": str}
__fillable__ = ["email", "name", "password"]
__casts__ = {"password": "hashed"} # auto-hashed whenever it's set
Authenticatable gives every instance get_auth_identifier() (the primary key by default) and
get_auth_password() (reads the password attribute) — plus await user.can(...) for
authorization checks.
The hashed cast is the important part: it hashes the password on every write — including the
mass-assignment create path — so you never call the hasher yourself and a raw password can't reach
the column:
form = await request.validate(RegisterForm) # validated {email, name, password}
user = await User.create(**form) # the cast stores password as an Argon2id hash
arvel's hasher uses Argon2id by default (bcrypt via driver="bcrypt") — see Hashing
and Casts & Serialization for the cast itself.
Keep the hashed cast
It's what guarantees the column only ever holds a hash. Don't drop it and assign a raw password,
and never compare passwords with == — let attempt/the guard do the (constant-time) comparison.
Logging a user in¶
AuthManager is the session — it tracks the current user and verifies credentials. The heart of it
is attempt(): you give it the submitted credentials and a user-provider callable that finds the
matching user; it verifies the password and, on success, logs them in.
from arvel.auth import AuthManager
auth = AuthManager()
async def find_user(credentials: dict) -> User | None:
return await User.where(email=credentials["email"]).first()
async def login(request):
credentials = await request.form() # {"email": ..., "password": ...}
if await auth.attempt(credentials, find_user):
return redirect("/dashboard") # logged in — session remembers them
return "Invalid credentials", 401
attempt returns True only when the user exists and the password matches the stored hash; it
never reveals which half failed (don't leak whether an email is registered).
Just the password check: verify_credentials¶
When you've already loaded the user yourself (a custom login flow that issues an API token, merges a
guest cart, …) and only need the password check, use verify_credentials — don't hand-roll
if user is None or not Hasher().check(pw, user.password). That idiom leaks two things: it
short-circuits the hash when the user is missing (an unknown email answers faster — a
user-enumeration timing oracle), and the sync check blocks the event loop on Argon2.
from arvel.auth import verify_credentials
user = await User.where("email", email).first()
if not await verify_credentials(user, password): # None user is fine — pass it straight in
abort(401, "Invalid credentials")
# … issue your token / start your session …
verify_credentials burns a dummy hash for a missing user (so an unknown email costs the same as a
wrong password) and runs the verify off the event loop (check_async). It returns True only
on a real match.
The session: who's logged in?¶
Once someone is authenticated, AuthManager exposes the session over a request-scoped
current_user:
auth.user() # the User instance, or None
auth.check() # True if someone is logged in
auth.guest() # True if nobody is
auth.id() # the current user's identifier, or None
auth.login(user) # log a user in directly (e.g. right after registration)
auth.logout() # forget them
In a request handler you usually reach for the active user through the request:
async def profile(request):
user = request.user() # the same current_user
if user is None:
return redirect("/login")
return {"email": user.email}
The current user is stored in a ContextVar, so it is isolated per request / per async task —
two concurrent requests never see each other's user.
AuthManager.login/attempt don't persist a session
auth.login(user) only sets the current_user ContextVar — it's the right call from a non-HTTP
context (a job, a console command) where there's no request to persist to, but on its own it means
every request re-authenticates from scratch. For a web login, use SessionGuard.login below.
Logging in over HTTP (SessionGuard)¶
A browser session needs two things AuthManager.login doesn't do: rotate the session id (so a
pre-login, possibly attacker-fixed, id is never reused — session fixation) and persist the user id
to the session (so the next request is still logged in). arvel.auth.guards.SessionGuard does both:
from arvel.auth.guards import SessionGuard
async def login(request):
credentials = await request.form()
user = await find_user(credentials)
if user is None or not await auth.attempt(credentials, find_user):
return "Invalid credentials", 401
await SessionGuard().login(user, request, remember=bool(credentials.get("remember")))
return redirect("/dashboard")
async def logout(request):
await SessionGuard().logout(request)
return redirect("/")
login(user, request, *, remember=False) regenerates the session id before writing the user id
(fixation defence), sets current_user for the rest of this request, and — when remember — issues a
rotating remember-me cookie (Routes & Flows).
logout(request) clears the remember cookie/token, invalidates the session (new id, all data
dropped), and clears current_user.
Wiring the read half: user_resolver¶
Persisting the user id to the session is only half the story — something has to read it back on the
next request. That's the app's user_resolver binding (Providers &
middleware); SessionGuard.user_id(request) is the primitive it calls
for a session-based app (mirrors TokenGuard.user_id(request) for the bearer-token path):
async def resolve_user(request):
user_id = await SessionGuard().user_id(request) # reads back what login() persisted
return await User.find(user_id) if user_id is not None else None
self.app.singleton("user_resolver", lambda app: resolve_user)
Without this, login() still rotates/persists correctly, but AuthenticateMiddleware has nothing to
read it back with — every request but the login one itself looks like a guest.
The local guard¶
Under the hood, "verify a request by some method" is the job of a guard (the full system is in
Guards & Drivers). The one behind password login is the local guard, and you can use
it directly — it reads email/username + password from the submitted form and returns a
Principal on success:
from arvel.auth.guards import GuardManager
guard = GuardManager().guard("local")
principal = await guard.verify(request) # Principal(provider="local", subject="[email protected]") or None
attempt() above is the convenient session-aware wrapper; the guard is the lower-level primitive you
reach for when you're composing your own flow or running more than one authentication method.
Throttling logins (brute-force lockout)¶
Unlimited password guesses are a brute-force invitation. Give AuthManager a LoginRateLimiter
and it locks out an identifier after too many failures within a window — counted over the cache, so
the limit holds across processes:
from arvel.auth import AuthManager
from arvel.auth.throttle import LoginRateLimiter
from arvel.cache import CacheManager
limiter = LoginRateLimiter(CacheManager().driver("redis"), max_attempts=5, decay_seconds=900)
auth = AuthManager(limiter=limiter)
await auth.attempt(credentials, find_user) # a locked-out identifier fails fast (no password check)
While locked, attempt returns False even for the correct password; each failed try is counted
and a successful login clears the counter. Show the user when they can retry:
if await limiter.too_many_attempts(email):
return f"Too many attempts. Try again in {await limiter.available_in(email)}s.", 429
Without a limiter, AuthManager doesn't throttle — it's opt-in. The identifier is normalised
(case/whitespace) so Ada@x and ada@x share one bucket, and on a cache outage the limiter
fails open by default (logins still work, but throttling is off — alert on cache downtime); pass
fail_open=False to fail closed instead.
Layer in IP throttling
Identifier-only lockout lets someone lock a known email out for the window. Put the IP-keyed
ThrottleRequests in front of the login route for a second dimension.
Confirming a password (sudo mode)¶
Some actions — changing email, deleting the account, rotating keys — should re-verify the password
even for an already-logged-in user. confirm_password checks it and records a confirmed-at stamp in
the session; EnsurePasswordConfirmed middleware then gates the sensitive routes:
from arvel.auth.confirm import confirm_password, EnsurePasswordConfirmed
async def confirm(request): # the "confirm your password" form posts here
if await confirm_password(request, (await request.form())["password"]):
return redirect(request.query("next") or "/settings")
return "Incorrect password", 422
kernel.alias({"password.confirm": EnsurePasswordConfirmed})
kernel.add_route(["DELETE"], "/account", close_account, group="web", middleware=["password.confirm"])
A route behind password.confirm returns 403 until the password was confirmed within the window
(default 3h; subclass EnsurePasswordConfirmed to change it). It needs the session, so use it in the
web group behind StartSession.
Re-confirming a password is an online guessing surface, so throttle it the same way you throttle
login — pass a LoginRateLimiter:
A locked key fails fast (no password check), each wrong password is counted, a success clears it, and
every wrong/locked attempt is audited on the security channel (auth.password_confirm.failed /
.locked).
Automatic password rehashing¶
arvel keeps stored password hashes current for you. When a correct login presents a password whose
stored hash is outdated — e.g. you later raised the Argon2 cost factor — attempt transparently
re-hashes it with the current parameters and saves. Hashes upgrade themselves as users sign in, with
no migration:
It's a no-op for a hash that's already current, a wrong password, or a user that can't persist
(override set_auth_password if your hash lives on a non-password field).
Common mistakes & gotchas¶
- Comparing plaintext. Never
==a password — letattempt/the guard compare against the stored hash. Thehashedcast handles the write side, so you don't hash by hand either. - Leaking which field was wrong.
attemptdeliberately returns a single boolean — show one "invalid credentials" message, not "no such email" vs "wrong password." - A
passwordfield without thehashedcast. Without it, an assigned password is stored in plaintext. Declare__casts__ = {"password": "hashed"}so every write is hashed automatically — then it's safe to keeppasswordin__fillable__and pass validated input straight tocreate. - Reading
auth.user()outside a request. The current user lives in aContextVarscoped to the request/task; in a job or CLI command there's no session user — pass the user explicitly. - Expecting
attemptto persist a cookie for you. It establishes the in-process session user; wiring it to a session cookie/middleware is the app's responsibility (see Middleware).
How it works¶
AuthManager holds the current user in a current_user ContextVar. attempt(credentials,
provider) resolves the user via your callable, then routes the password check through the local
guard — which compares the submitted secret against the stored hash with security.Hasher (Argon2,
constant-time). On success it calls login(), which sets the context variable; logout() clears it.
Because the store is a ContextVar, the session is concurrency-safe without any thread-local
juggling. Correct logins on stale hashes upgrade transparently (see
Automatic password rehashing).
See also¶
- Guards & Drivers — the engine behind every login method, and how to run several.
- Identities & Account Linking — let one person log in by password and SSO.
- Single Sign-On (OIDC / Keycloak) — authenticate through an identity provider.
- Authorization — once you know who they are, decide what they may do.