How n8n, PostHog, and Supabase keep tenants isolated
The same review that turned up 78 cross-tenant leaks also turned up plenty of products that held. I read the ones that passed to see what they did differently. Three patterns kept showing up, and all three come down to one idea: put isolation somewhere a single endpoint cannot forget it.
A while back I source-reviewed 200+ self-hosted AI and SaaS products for one class of bug: an authorization check enforced on the write path but skipped on a neighboring read. Seventy-eight of them leaked across tenants. That post was about the ones that failed. This one is about the ones that held.
Because plenty did. When I went looking for the cross-tenant read in n8n, PostHog, and Supabase, it was not there, so I read each of them closely to see what they were doing that the leakers were not. The stacks could not be more different (a TypeScript workflow engine, a Python analytics platform, an Elixir realtime service), but the same three patterns kept coming up. All three are open source, so every line I reference below is something you can go read yourself.
Here is the thread that ties them together, stated up front. In the products that leaked, isolation was opt-in: each new read had to remember to add the tenant check, and eventually one shipped that did not. In the products that held, isolation was structural: the code falls into it by default, and forgetting is not an available option. Here are three ways to get there.
Pattern 1: put the tenant filter in a shared finder, not each endpoint (n8n)
n8n's authorization decorators look uneven if you audit them one by one. Some
reads are guarded with @ProjectScope('workflow:read'). Others,
like the endpoint that lists data tables across a workspace, carry only
@GlobalScope('dataTable:list'), an instance-level role check that
says nothing about which projects you actually belong to. Read the decorators
alone and you would flag that as a cross-project leak.
It is not one, and the reason is the whole point. The decorator is a hint; the
query underneath is the guard. That aggregate list calls
getAccessibleProjectsByRoles(user) and filters its result down to
the projects the caller is actually a member of. Every by-id read in the
public API (GET /workflows/:id, /executions/:id,
/credentials/:id) routes through a shared finder,
WorkflowFinderService.findWorkflowForUser or
getSharedWorkflowIds, whose query binds
projectRelations.userId before it returns anything. Credentials
get the same treatment and have their secret field stripped on top.
So the scope does not live in the decorator, and it does not live in the handler. It lives in the finder that every read has to call to get its data at all. A developer adding a new endpoint gets tenant scoping for free, because the only way to load the object is through code that already applies it. This is exactly why n8n held where less careful codebases leaked: same apparent decorator gap, but the query beneath it was bound to the caller the whole time.
Take it home: centralize the tenant filter in a repository or finder that every read goes through. Make the unscoped query the one that is hard to write, not the easy default.
Pattern 2: make the missing check fail the build (PostHog)
PostHog has the same team-and-project shape as everyone else, and a raw
Conversation.objects.get(id=...) with no team filter would be
precisely the bug from the roundup. The difference is that PostHog will not let
that line through CI.
They run a semgrep rule, idor-lookup-without-team, that flags any
by-id database lookup not scoped to a team. Where a raw lookup is genuinely
necessary, the developer suppresses the rule inline with
# nosemgrep: idor-lookup-without-team and a written reason, and
every suppression I checked had a real one: "ownership checked two lines
below," "internal worker, team passed as a parameter," and so on. Even the
assistant's streaming endpoint, which derives a Redis key from a run id, only
gets there after TaskRun.objects.filter(pk=run_id, team_id=team_id)
has confirmed the run belongs to the caller's team.
The insight is that this bug class is mechanical. The check is always the same shape (the object id is bound to the caller's tenant), and so is its absence. Anything that mechanical can be linted. A rule like this turns "did every developer remember the tenant filter on every read?" into a build gate that answers itself, and the suppress-with-a-reason convention leaves an audit trail of every deliberate exception for the next reviewer.
Take it home: if your cross-tenant bug has a consistent code shape (and it almost always does), write a lint for it. You can add a rule like PostHog's to your own repo in an afternoon, and it will outlast every code reviewer's attention span.
Pattern 3: one unforgeable tenant identity, validated everywhere (Supabase Realtime)
Supabase's Realtime service serves many tenants from a single deployment, one per project, so the obvious risk is a client authenticated for tenant A subscribing to tenant B's channel. It closes that in two moves.
First, it derives the tenant from an unforgeable source and validates against
it consistently. The tenant comes from the request Host
(get_external_id(host)), and the caller's JWT is then checked
against that specific tenant's secret, the same way at every entry point: the
HTTP broadcast endpoint, the WebSocket connect, all of them. There is no global
secret to fall back on. An attacker can put tenant B's subdomain in the Host
header and be assigned tenant B, but the JWT still has to be signed by tenant
B's secret to pass, so the "who is this tenant" decision and the "is this
credential valid" decision can never disagree.
Second, it namespaces every internal channel by tenant. The helper that builds
pub/sub subjects, tenant_topic, prefixes the tenant's id onto
every one (<external_id>:room for public topics, a distinct
-private variant for private ones), and the postgres-changes and
presence subjects do the same. Two tenants' subscribers cannot land on the
same subject even by accident, because the subject strings never overlap.
Take it home: pick one authoritative answer to "who is this tenant," validate every credential against that answer rather than a shared key, and prefix your internal pub/sub subjects and cache keys with the tenant id. Then cross-tenant delivery is not something a check has to prevent. It is impossible by construction, because the names do not collide.
The common move
Three stacks, three mechanisms: a shared finder, a CI lint, a consistent auth layer with namespaced topics. Underneath, they are all doing the same thing. They put isolation somewhere the individual endpoint cannot skip, so that the safe path is also the default path.
That is the exact inverse of what the 78 leakers did. There, isolation was a checkbox on every read, and the wall held right up until someone shipped a read that forgot to tick it. The lesson from the ones that held is not "remember harder." It is to move the check somewhere it cannot be forgotten: into the query layer (a scoped repository, a row-level policy), into CI (a lint on the bug's code shape), or into a single resolution-and-namespacing layer if you route messages or cache responses. Most teams shipping multi-tenant AI want more than one of these.
Reading the code is not the same as verifying it
Everything above I found by reading source. But source review tells you the pattern is present, not that it holds on every endpoint, including the one merged last Tuesday. Patterns get bypassed by the next feature. That is the gap Sectum AI closes: it stands up two tenants with synthetic data and actually tries to cross the wall, running the full battery (IDOR/BOLA, RAG entity-bleed, cache contamination, erasure) on every release and emitting a signed evidence pack. Read the code to see whether the pattern is there. Run the check to know it still is.
Explore the open source Read the roundup Request an assessment
Companion to the 78-leaked roundup. The full current disclosure list, updated as each advisory publishes, is at sectum.ai/research.