Cross-account isolation

A stack of independent checks — URL, session, membership, query, cache — each of which alone would keep one company's data out of another's screen.

What it is

Isolation is enforced at five layers rather than one: the middleware refuses a page request for an account you're not a member of, the server auth layer refuses the same request at the API, the data proxy stamps and filters org_id on every statement, the browser cache is purged when the viewed account changes, and a CI script fails the build when a new hand-written query forgets its scope.

Also called: data isolation · can another company see my data · tenant separation · privacy between accounts

See it
Cross-account isolation
Area
Platform & Admin
Group
Tenancy & isolation
System
Multi-Tenancy & Identity
Solves
3 named problems
01Middleware compares the subdomain-derived org against the user's memberships and bounces a mismatch back to the apex with the impersonation cookie cleared.
02serverAuth.getAuthedUser repeats the check server-side and returns null — which routes turn into a 401 — when the user has memberships but none matching the requested subdomain.
03/api/db appends the caller's org_id to every select, update and delete and stamps it onto every insert, ignoring any org_id the client sent.
A layered diagram: request → middleware gate → server auth gate → org-stamped query → scoped result, with the browser cache purge hanging off the side. No single component renders this; it is a concept diagram sourced from src/middleware.ts and src/app/api/db/route.ts. Sample data — no customer information appears here.
How it works
  1. 1Middleware compares the subdomain-derived org against the user's memberships and bounces a mismatch back to the apex with the impersonation cookie cleared.
  2. 2serverAuth.getAuthedUser repeats the check server-side and returns null — which routes turn into a 401 — when the user has memberships but none matching the requested subdomain.
  3. 3/api/db appends the caller's org_id to every select, update and delete and stamps it onto every insert, ignoring any org_id the client sent.
  4. 4The browser purges all app localStorage whenever the viewed account changes.
  5. 5scripts/check-org-scoping.mjs statically flags any new direct query against an org-scoped table that never mentions org_id.
Why we built it

The comment in serverAuth names the incident that produced the whole stack: before the membership check, the function 'returned whatever orgId the URL pointed at without verifying the user was actually a member. A worker on tenant A could type tenantB… and see all of tenant B's data with worker permissions.' It was discovered when a worker legitimately invited to one builder's account navigated to another builder's subdomain and saw an eight-figure pipeline that was not his employer's. The layered design is stated in the same block: API requests get a 403, page requests get a redirect, 'so this manifests as you got redirected rather than a stuck error screen.' The standing rule in this codebase is that isolation is absolute — no sub-account's data, notifications or business details may ever reach another sub-account.

The problem
  • A signed-in user guessing another company's URL could read that company's data.
  • One forgotten filter in one hand-written query is a silent cross-account leak with no database backstop.
  • A browser that has viewed two accounts could paint one account's cached data under the other's name.
Sound familiar?
What you get
Typing another company's address gets you redirected, not a data screen.
Every query is scoped by the server, not by the page that asked.
New unscoped queries fail the build before they can ship.
What's inside
Wrong-account URL gets you bounced
If you open an account address you don't belong to, the page redirects you home instead of rendering anything.
The API checks membership too
Even if a request slips past the page-level gate, the server refuses to resolve an account the caller isn't a member of.
Every query scoped by the server
The browser never talks to the database directly — it asks a server proxy that stamps your account id on every write and filters every read by it.
Switching accounts wipes the browser cache
The moment the account you're viewing changes, everything the app cached in the browser is deleted — everything except your login and your light/dark preference.
The name on screen always matches the data
The app asks the server which account it is actually serving, and if that disagrees with what's on screen it purges and reloads once rather than showing a mismatched label.
Per-account browser storage
Everything the app stores in the browser is silently prefixed with the account's subdomain, so any legacy entry from another account becomes unreadable.
Unscoped queries fail the build
A static check scans every hand-written database query in the codebase and fails the build when a new one forgets to filter by account.
No silent default account
A write that forgets to say which account it belongs to now fails loudly instead of quietly landing in the first customer's data.

See it on your own jobs

Twenty minutes, your numbers, no slide deck. We’ll build one of your real buildings in front of you and send you the estimate link at the end — yours to keep either way.

or keep browsing features →