Personal finance aggregator: Auth, two-factor, and account settings - Part 6
~10 min read
Status: superseded. Everything described below was built, shipped and then deleted within about two days. On 28 September, two commits moved login, passwords and two-factor out of this app entirely and into a separate identity-system service that nginx consults before any request reaches the app. The post stays because the design reasoning (and the gaps it ran into) are the reason that move happened - the full story is in post 9.This is a single-user app sitting in front of real bank transaction data, which makes "how does login work" a question worth taking seriously even though there's only ever going to be one account. I didn't want to hand-roll password hashing or session management for a side project - that's exactly the kind of code where a subtle mistake sits quietly wrong for months - so authentication here is handled by a small library I wrote separately, sessionkit, rather than inline in this app. This post is about the parts that actually touch this project: wiring it in, building two-factor authentication on top of it, and one genuinely awkward gap I had to design around when I split account settings into sections.
What sessionkit provides
sessionkit's AuthService covers the basics you'd expect: create a user with Argon2id-hashed passwords, log in and get back an opaque session token, validate a token back to a user, log out. On top of that, opt-in TOTP two-factor authentication - enroll, confirm with a code from an authenticator app, disable, regenerate recovery codes - plus session invalidation when it matters (disabling 2FA, changing a password).
Wiring it into FastAPI is a dependency and a cookie:
SESSION_COOKIE = "session_token"
def current_user(request: Request, auth: AuthService = Depends(get_auth_service)) -> User:
return auth.user_for_token(request.cookies.get(SESSION_COOKIE))
Every authenticated route takes user=Depends(current_user). An invalid or missing token raises an error that a global FastAPI exception handler turns into a 401 - no route has to remember to check auth itself, it's structural.
Why Argon2id, and why a cookie instead of a JWT
Two choices sit underneath everything above that are easy to gloss past but worth stating deliberately, because both were decisions rather than defaults.
Password hashing uses Argon2id specifically, not bcrypt and not a hand-rolled PBKDF2 call. Argon2id is the current OWASP recommendation for password storage - it's memory-hard, which makes it meaningfully more resistant to GPU-accelerated cracking attempts than bcrypt if a password database ever did leak, and "memory-hard" was a property worth having even for a database with exactly one row in its users table. The actual hashing call is one line via sessionkit; the decision that mattered was picking a library that defaults to a modern algorithm rather than one that defaults to whatever was best practice a decade ago and never revisited.
Sessions are opaque server-side tokens in an httponly cookie, not a JWT. A JWT would let the API validate a request without a database lookup - the whole appeal of JWTs is statelessness - but that statelessness is exactly what makes revocation hard: an issued JWT is valid until it expires, full stop, unless you build a denylist, at which point you've recreated server-side session state anyway just to get revocation back. Password changes and 2FA disables in this app need to invalidate sessions immediately, not "whenever the token happens to expire," so an opaque token backed by a real, deletable row in a sessions table was the right trade from the start - one extra database lookup per authenticated request, in exchange for revocation that actually revokes something the moment it's asked to.
Two-factor, the parts worth calling out
The TOTP flow is standard - enroll gets you a secret and a otpauth:// URI to put in an authenticator app, confirm proves you actually set it up correctly by requiring a valid code before it's turned on, and from then on login requires that code too:
@router.post("/2fa/enroll", response_model=TotpEnrollmentOut)
def enroll_totp(user=Depends(current_user), auth: AuthService = Depends(get_auth_service)):
enrollment = auth.start_totp_enrollment(user.id)
return TotpEnrollmentOut(secret=enrollment.secret, uri=enrollment.uri)
The one design choice worth flagging: enrolling doesn't invalidate your current session, but a fresh login call afterward requires the OTP. That's deliberate - you're mid-setup, still authenticated, looking at a QR code; forcing a re-login at that exact moment would be actively hostile UX. The requirement only bites the next time you authenticate from scratch, which is exactly when it should.
Recovery codes, and the one-time-display problem
Confirming TOTP enrollment returns a batch of recovery codes - one-use backup codes for getting back in if I lose my phone or the authenticator app gets uninstalled. These get shown exactly once, in the response to the confirm call, and never again:
@router.post("/2fa/confirm")
def confirm_totp(payload: ConfirmTotpRequest, user=Depends(current_user), auth=Depends(get_auth_service)):
recovery_codes = auth.confirm_totp(user.id, payload.otp)
return {"recovery_codes": recovery_codes}
sessionkit stores only hashes of these codes, the same way it stores password hashes - never the codes themselves - which means there's no "view your recovery codes again" feature to build even if I wanted one, because the plaintext genuinely doesn't exist anywhere after that one response. The frontend's job here is entirely about not squandering that single moment: the confirm screen shows the codes in a fixed-width list with a clear "save these somewhere safe, you won't see them again" warning, and nothing else on that screen competes for attention. Get this UX wrong - bury the codes below the fold, let the success state auto-dismiss after a few seconds - and the result is a support burden of exactly one user, me, locked out with no way back in except deleting the 2FA enrollment from the database by hand.
Splitting settings into sections, and hitting a real gap
The settings page originally had two-factor controls bolted onto the Home page dashboard, which made no sense once Home became a proper stats page - security settings don't belong wedged between spending charts. Splitting it into a real Settings page with three sections - User account, Password, Two-factor authentication - seemed straightforward, right up until I went to build the first one.
sessionkit's User model has exactly five fields: email, name, id, created_at, totp_enabled. There is no update_user method anywhere in the library - not on AuthService, not on the underlying store protocol. Name and email get set once, at account creation, and that's it. I own the library, so I could add that capability - but that's a change to a separate package with its own version to bump, not something to reach for mid-way through a settings page in a different repo. I'd also, briefly, considered writing directly to sessionkit's SQLite table from this app instead - which would have worked, right up until the next time I changed that table's schema in the library and silently broke it from the outside without either project's tests catching it. Reaching around a library's own storage layer like that trades a real, contained problem (no update method) for a much worse one (an undocumented, unversioned coupling between two codebases that only I would ever remember exists).
So: the User account section is read-only. Name and email, displayed, not editable, with a short comment in the code explaining why rather than a silently missing feature someone might "fix" without knowing the constraint. Honest and unglamorous, and a much better trade than either of the alternatives.
Changing a password, correctly, with a gap of its own
set_password exists and works, but has an interesting asymmetry compared to the rest of the library:
def set_password(self, user_id: str, password: str) -> None:
No current_password parameter. Compare that to disable_totp, which takes one and checks it internally before disabling anything. For password changes there's no equivalent built-in check - call set_password with a valid user_id and it just changes the password, full stop. That's a reasonable design for a library that assumes callers already know what they're doing (an admin CLI, say), but a "change password" form on a settings page absolutely should confirm you know the current password first - protection against someone getting to your unlocked laptop, not against someone who's already got your password.
Rather than reach into the library's private internals to add that check myself, I reused the one thing already public and already does exactly that verification: login().
@router.post("/password")
def change_password(
payload: ChangePasswordRequest,
response: Response,
user=Depends(current_user),
auth: AuthService = Depends(get_auth_service),
):
# set_password() has no built-in current-password check - login() is
# reused here to verify it, which also gets 2FA-aware handling for
# free. The session login() creates is never handed to the client.
try:
auth.login(user.email, payload.current_password, payload.otp)
except OtpRequired:
return JSONResponse(status_code=401, content={"otp_required": True})
except AuthenticationError:
return JSONResponse(status_code=401, content={"detail": "Current password is incorrect"})
auth.set_password(user.id, payload.new_password)
response.delete_cookie(SESSION_COOKIE)
return {"status": "ok"}
Calling login() and just discarding the session it issues is a slightly unusual pattern, but it buys two things for free: proper current-password verification using code that's already tested, and 2FA-aware handling for accounts that have it enabled - if you've got TOTP on, this form now requires a code too, exactly like logging in would, without writing any of that logic myself.
The other consequence is one I had to design the frontend around, not just the backend: set_password invalidates every session for that user, including the one that just made this very request. Change your password and you're logged out - not as a bug, as correct behaviour, but the settings page needs to know that a "successful" password change response actually means "now show the login screen," not "now show a success toast and keep going."
The shape this settled into
Three sections, three different levels of "how much can this actually do": account details you can see but not edit, a password you can change with proper verification (and a session that ends the moment you do), and 2FA that's fully self-service. None of that needed inventing new infrastructure - it needed being honest about what the underlying library actually supports, and building around the gap instead of papering over it with something fragile.
Auth handles who's allowed to see the data. Next post is about the data itself getting stale, and what "keeping it fresh" actually took once a sync could fail halfway through.
Previous: ← Building the frontend · Next: Keeping data fresh →
