The JWT carries real weight here, not just identity. Every access token embeds both user_id and tenant_id, and every request re-verifies that claim against the database rather than trusting it outright: get_active_context decodes the token, then checks a live Membership row for that exact user/tenant pair with ACTIVE status before the request goes any further. Someone claiming to belong to a family isn’t taken at their word, it’s checked every time.
Dependency Injection for Tenant Context
get_active_contextis the one dependency almost every router depends on: decode the token, load the user and tenant, verify an ACTIVE membership between them, and hand back anActiveContextcarrying all three. It lives indeps.py(full router/service layout on Backend Architecture), and it’s the single place this logic exists, not reimplemented per route.- A narrower dependency,
get_authenticated_user, skips the membership check entirely. It exists for endpoints that only need identity, not tenant scope, listing, creating, or switching families, so a user whose active-tenant membership got removed isn’t locked out of the one endpoint that would let them switch to a different family. - RBAC sits on top as its own layer.
require_role(*roles)is a dependency factory checked against the resolved membership’s role, with two named aliases actually used across routers:
require_owner = require_role(MembershipRole.OWNER)
require_writer = require_role(MembershipRole.OWNER, MembershipRole.MEMBER) # i.e. "not viewer"
Centralizing it this way means the role check isn’t reimplemented per route.
Hardening Pass
The refresh-token reuse detection, the cross-tenant write guard, and the composite indexes weren’t day-one design, they came out of an automated QCSD security scan (2026-06-10) that flagged real findings: a cross-tenant write bug where any authenticated member could corrupt another tenant’s account balance, an IDOR on GET /accounts/{id}, and refresh-token replay. One PR closed all of them, backed by new failing-first tests for each.
The exposure was low. The backend isn’t reachable from outside my home network or the AWS demo’s own perimeter, so none of this was exploitable by a random attacker on the internet. But fixing it was still the right call, good hygiene doesn’t depend on what’s currently exposed, and if the deployment model ever changes, the fixes are already in place instead of needing to be retrofitted under pressure.