How We Prevent Tenants from Seeing Each Other's Data Without Thinking About It
Every SaaS product that serves multiple customers from a shared database has to solve the same problem: make sure tenant A can never see tenant B’s data, regardless of how clever or careless the application code is.
There are many ways to get this wrong. We’ve seen APIs that return 403 when a tenant tries to access another tenant’s resource — which confirms that the resource exists and leaks information. We’ve seen APIs that return the resource with fields scrubbed — which is worse, because it implies the caller has some partial visibility. The right answer is almost always to return 404: if a resource doesn’t belong to you, it doesn’t exist from your perspective.
This post describes how we structured multi-tenant scoping in smplkit so that correct behavior is the path of least resistance, not the thing you have to remember to add.
The Problem with Explicit Tenant Checks
The naive implementation of multi-tenant scoping puts the burden on every route handler: check that the requested resource belongs to the authenticated account before returning it. This works when you remember to do it. It fails silently when you forget. And when you have dozens of routes across multiple services, you will forget.
The failure mode is ugly. An attacker who knows or guesses a UUID can enumerate resources across tenants by cycling through IDs. This class of vulnerability — Broken Object Level Authorization, or BOLA, sometimes called IDOR — is consistently in the OWASP API Security Top 10 not because it’s exotic but because it’s easy to introduce by omission.
We needed a model where correct scoping was structural, not something that had to be consciously applied on each route.
The Options We Considered
Per-route account checks. Explicitly filter every query by account ID. The simplest mental model. The most fragile in practice. One missed check = a potential data exposure.
Middleware that validates after the fact. Fetch the resource, then verify the account matches. Catches mistakes but still requires remembering to call the middleware. Also creates a window where a 404 should have been returned but wasn’t.
URL-based tenant disambiguation. Route all resources under /accounts/{account_id}/.... Parse the account ID from the URL, validate it against the authenticated principal. Some APIs do this. It’s explicit but verbose, and it means tenant ID is in every URL — which complicates caching, logging, and client code.
Implicit scoping from the authenticated identity. The JWT carries the account ID. Every request is automatically scoped to the authenticated account without any route handler needing to think about it.
What We Built
We use implicit scoping from the authenticated identity, with a clean URL structure that doesn’t expose account IDs at all.
All resource endpoints use the pattern /api/v1/{resource_type}/{resource_id} — no account ID in the URL. When a request arrives, the account ID is read from the JWT and made available as a dependency. Every database query includes account_id = current_account_id as a filter condition, applied at the ORM layer before the query executes.
The URL /accounts/current is how callers access the authenticated account’s own metadata — it resolves to the account associated with the JWT, never requiring the caller to know or supply their account UUID.
The consequence: if you query /api/v1/flags/some-flag-id and that flag belongs to a different account, the ORM query returns no rows, and the response is 404. The same response you’d get for a flag that doesn’t exist at all. There is no information leakage about whether the resource exists under a different tenant.
404, Not 403
This is a deliberate design choice worth explaining.
403 Forbidden communicates “I know what you’re asking for and you can’t have it.” It’s appropriate for authorization failures within your own tenant — you’re authenticated but lack the role to perform this action. It should not be used for cross-tenant access.
If I receive a 403 when I query /flags/abc123, I know that abc123 is a valid flag ID that belongs to someone. I can enumerate IDs until I find valid ones across tenants. That’s an information leak.
404 Not Found communicates “I don’t know what you’re asking for.” Applied consistently to cross-tenant access, it prevents enumeration: there is no signal distinguishing “this ID exists but belongs to someone else” from “this ID doesn’t exist.”
The downside: legitimate callers who fat-finger a URL or use a stale ID get a 404, which can be confusing. We think this is the right trade-off. You know what IDs belong to you. If you’re getting 404s, you’re asking for things that aren’t yours — either a bug or an attack.
Row-Level Security as the Backstop
We layer PostgreSQL’s row-level security (RLS) on top of the application-level scoping as a defense-in-depth measure. Even if application code somehow failed to apply the account filter — a bug, a migration that forgot to update a query, a new developer who didn’t read the conventions — the database rejects the query at the row level.
RLS policies are defined per table: USING (account_id = current_setting('app.current_account_id')::uuid). The application sets this context variable at the start of each request. Queries that would return rows belonging to other tenants return nothing, or raise an error, depending on the policy.
This is belt-and-suspenders, not a replacement for application-level scoping. We want both layers catching mistakes independently. (We wrote a separate post on our full RLS implementation — that’s ADR-043, which covers the PostgreSQL mechanics and the SQLAlchemy integration in detail.)
The Shared Account Resolver Pattern
Each service has a single FastAPI dependency that resolves the current account from the JWT: get_current_account. Every protected route declares this as a parameter. It doesn’t return an account ID string — it returns the full account object, which includes account metadata the route might need. The account ID within that object is what the ORM uses for filtering.
This creates a consistent callsite: every route that touches account-scoped data has account: Account = Depends(get_current_account) in its signature. It’s easy to audit which routes are protected and which aren’t.
Public endpoints (health checks, OpenAPI spec) don’t declare this dependency. The distinction is obvious from the function signature. There’s no implicit behavior to infer.
Cross-Service Calls
Some operations require one product service to call another. The flags service doesn’t query the app service’s database directly — it calls the app service’s internal API to retrieve account information it needs.
Internal calls use a separate auth mechanism (an APP_AUTH_SECRET shared secret, not a customer JWT). The receiving service validates the internal auth token and, when the call includes an account scope, applies the same account scoping logic it would for any other request. A compromised product service cannot escalate to cross-tenant access because each service enforces scoping independently.
Invitations and Multi-Account Users
One edge case worth noting: a user can belong to multiple accounts. A user invited to two organizations has two account memberships. Their JWT, at any given time, carries exactly one account ID — the one they most recently authenticated for. Switching accounts requires re-authentication or an explicit context switch.
This simplifies the scoping model considerably. There’s no concept of “a request that crosses account boundaries.” Each request has exactly one account context. If you want to act on behalf of account B, get a token for account B.
Account invitations are handled through a separate flow where the invitation token carries the target account ID. The acceptance endpoint is the one place where the acting user’s current account and the target account are both in play — it’s explicitly coded to handle that, rather than relying on the implicit scope.
What We’d Revisit
The main limitation of our current model is that the account ID in the JWT is determined at login time and doesn’t update dynamically. If a user’s account membership changes (they’re removed from an organization, their role changes), the change takes effect when their current JWT expires. For our use case — typically hours-long sessions — this is acceptable. A product with stricter requirements might want shorter JWT lifetimes or a revocation mechanism.
We also don’t yet have audit logging that captures “this request accessed these row IDs.” Row-level logging would let us detect patterns that suggest someone is probing for cross-tenant data (lots of 404s from a single account on resources they don’t own). That’s on the roadmap.
smplkit’s multi-tenant scoping is consistent across all product services. The same account resolver, the same 404-not-403 policy, the same ORM-layer filtering applies whether you’re working with flags, config, or logging resources.