Where does the tenant id live?
Part 8 of an ongoing series on Moty, a fitness data platform I build and run solo in production: one multi-tenant API, two Next.js frontends. All numbers come from production measurements and git history.
One question, three homes
Every multi-tenant system has to answer one small question everywhere: when a request arrives, which tenant is it about, and is this user allowed to act there? Most designs pick one home for the tenant id and stick with it. Ours ended up with three different answers (the token, the URL, and a cookie), and the interesting part is that each choice was made against a different failure mode.
The platform's tenants are fitness centers. A user has one global account that can belong to several centers with different roles: an owner here, a staff member there. That one-account-many-memberships shape is what makes the question sharp.
Answer one: not in the token
The obvious move is to stamp the tenant id and role into the JWT at login. We deliberately don't, and the reason is written in the code as documentation: claims go stale.
A token lives for its full lifetime no matter what happens in the world. If someone is dismissed from a center at 2pm, a tenant claim minted at 1pm keeps vouching for them until it expires. Membership and roles gate other people's personal data, and for facts like that, staleness measured in hours is not an acceptable failure mode. So the token carries only who you are; which centers you belong to, and as what, is resolved from the database on every request.
The cost is one membership lookup on every tenant-scoped request. We weighed it and decided instant revocation is worth a cheap indexed read at our scale. The roadmap if that ever changes is a short-TTL cache with explicit invalidation on role changes. But caching is an optimization you add when you must, and staleness is a bug you design out from day one.
Answer two: in the URL path, on the server
Server-side, the tenant id lives openly in the path: every tenant-scoped endpoint is shaped like /tenants/{id}/.... An authorization aspect intercepts these calls, pulls the tenant id out of the path, resolves the caller's membership and role for that tenant, and rejects the request before any handler code runs.
The URL is the right home here because it makes tenancy explicit and auditable. There is no ambient "current tenant" hiding in session state on the server; every request names its tenant where log lines, access logs, and code review can all see it.
Answer three: not in the browser's URL
The frontend inverts that choice. In the operator web app, the active center id never appears in the browser's address bar. The user picks a center once; the choice is stored in an httpOnly cookie; and the BFF, the only thing the browser talks to, injects the id into the server-side path when proxying the call.
Two failure modes drive this. First, URLs leak: they get copy-pasted into chats, saved in bookmarks, kept in browser history on shared machines. A tenant identifier isn't something people should be carrying around. Second, editable URLs invite tampering: an id in the address bar is a text field asking to be incremented. The server would reject a forged id anyway (enforcement lives there), but removing the surface entirely means the attempt can't even be typed.
The honest limits
This is not row-level security. Tenancy is enforced by the path convention, the authorization aspect, and query discipline inside services, not by the database refusing to return another tenant's rows. A service query that forgot its tenant condition would not be stopped by a last line of defense in the database. The aspect blocks wrong-tenant entry, which covers the front door. Past that, correctness rests on query discipline inside the services. If the platform grows past what review-and-convention can carry, the next step is Postgres row-level security or a tenant-aware repository layer; I'd rather name that now than discover it in an incident.
What I'd pass on
Don't ask "where should the tenant id live?" Ask what goes wrong in each place, and how fast you need it corrected. Identity changes rarely, so a token can carry it. Permissions change suddenly and matter immediately, so we resolve them fresh. The UI's selection is a convenience that can leak, so it sits in a cookie page scripts can't read. Three homes, one line of reasoning behind all of them.
0 comments