How we prevent tenants from seeing each other's data without thinking about it
Every SaaS product serving multiple customers from a shared database has the same non-negotiable: tenant A can never see tenant B’s data, no matter how clever or careless the application code gets.
There are established ways to get this wrong. APIs that return 403 for another tenant’s resource — confirming the resource exists, which is itself a leak. APIs that return the resource with fields scrubbed, which is worse, because it implies partial visibility. The right answer is almost always 404: if a resource doesn’t belong to you, it doesn’t exist from your perspective.
This post is about how we structured smplkit’s scoping so the correct behavior is the path of least resistance instead of a thing someone has to remember.
The problem with remembering
The naive implementation puts the burden on every route handler: check that the resource belongs to the authenticated account before returning it. It works when you remember. It fails silently when you forget, and across dozens of routes in multiple services, someone will forget.
The failure mode has a name — Broken Object Level Authorization, or BOLA, sometimes IDOR — and it sits in the OWASP API Security Top 10 year after year, not because it’s exotic but because it’s so easy to introduce by omission. An attacker who can guess UUIDs enumerates until something answers.
We wanted scoping that’s structural. We considered making every route filter explicitly (simplest to explain, most fragile in practice — one missed check is one exposure). We considered after-the-fact middleware that verifies the fetched resource’s account (still something to remember, plus a window where the wrong answer already happened). We considered putting the account ID in every URL, /accounts/{account_id}/... — explicit, verbose, and now the tenant ID is in every cache key, log line, and client. What we built is the fourth option: the JWT already knows who you are, so every request is scoped to its account implicitly, and no route handler thinks about it at all.
What we built
All resource endpoints look like /api/v1/{resource_type}/{resource_id} — no account ID anywhere in the URL. The account ID comes off the JWT and every database query gets account_id = current_account_id applied at the ORM layer before it executes. (Your own account’s metadata lives at /accounts/current, resolved from the token — callers never supply their account UUID.)
The consequence: query /api/v1/flags/some-flag-id for a flag that belongs to someone else, and the ORM returns no rows, so the response is 404 — the identical 404 you’d get for a flag that never existed. No signal distinguishes the two.
404, not 403
This deserves its own defense, because 403 feels more “correct” to a lot of engineers.
403 says “I know what you’re asking for and you can’t have it.” That’s the right answer for role failures inside your own tenant. Across tenants, it’s an oracle: get a 403 probing /flags/abc123 and you’ve learned abc123 exists and belongs to someone — keep cycling IDs and you’re mapping other people’s infrastructure. 404 says “I don’t know what you’re asking for,” and applied consistently it makes enumeration yield nothing at all.
The cost is that a legitimate caller with a stale or fat-fingered ID gets a 404 where a 403 might have been more informative. We’ll take that trade. You know which IDs are yours. 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
Underneath the application scoping sits PostgreSQL row-level security: per-table policies like USING (account_id = current_setting('app.current_account_id')::uuid), with the context variable set at the start of each request. If application code ever fails to apply the filter — a bug, a forgotten query in a migration, a developer who didn’t read the conventions — the database returns nothing rather than someone else’s rows.
Belt and suspenders, deliberately: two independent layers, each capable of catching the other’s mistake. The PostgreSQL mechanics and the SQLAlchemy integration have their own post.
The shared account resolver
Every service has one FastAPI dependency, get_current_account, that resolves the JWT to a full account object. Every protected route declares it: account: Account = Depends(get_current_account). That one consistent callsite makes protection auditable at a glance — grep the signatures and you know which routes are scoped. Public endpoints (health checks, the OpenAPI spec) simply don’t declare it, and the difference is right there in the signature.
Cross-service calls
When one product service needs another’s data, it calls that service’s internal API — never its database. Internal calls authenticate with a separate platform mechanism, not customer JWTs, and when a call carries an account scope, the receiving service applies the same account scoping it would to a customer request. A compromised service can’t escalate into cross-tenant reads, because the service on the other end enforces scoping independently and doesn’t relax it for internal callers.
Multi-account users
One edge case worth spelling out: a user can belong to several accounts, but their JWT carries exactly one account ID at a time — the one they authenticated for. Switching accounts means re-authenticating or an explicit context switch. That rule keeps the whole model simple: there is no such thing as a request that crosses account boundaries. Want to act on account B? Get a token for account B.
The invitation flow is the one place two accounts legitimately meet — the invitation token carries the target account — and that endpoint handles it explicitly rather than leaning on implicit scope.
That’s the entire system: one resolver, one filter applied where queries are built, one status code for everything that isn’t yours, and a database that assumes the application will eventually make a mistake.