npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tenant-guard

v0.42.0

Published

Guard tests that fail your CI when multi-tenant code can leak across tenants. 22 guards: static ones run zero-dependency, runtime ones prove one tenant can't read OR write another — reads, writes, tenant-hop, INSERT, views, materialized views, partitions,

Readme

tenant-guard

Guard tests that fail your CI when multi-tenant code can leak across tenants.

Not a scanner you run and then ignore. These are checks that live in your repo, run in npm test and your CI, and block the merge — so the cross-tenant leak never ships. The static guards have zero dependencies and run in CI without npm ci; the runtime guards add real Postgres proofs when you opt in.

npx tenant-guard init     # detects your migrations + API routes, writes a config
npx tenant-guard run      # static guards — exit 1 if anything can leak
npx tenant-guard all      # everything: static guards + every runtime proof

Or run one at a time: prove, drift, anon-reads, anon-writes, identity, rpc, views, storage, realtime, oracles, shadows, caps, schemas, pooler, defaults, fks, creates, mfa, columns, triggers. The view write-through check runs inside run, with no database. npx tenant-guard list describes each, and --help documents the rest.

Every command also speaks machine: --json for anything downstream, --sarif for GitHub code scanning, --markdown=$GITHUB_STEP_SUMMARY for the run page. The exit code is identical in all of them.


The problem

AI code generators — and tired humans — ship the same multi-tenant bugs over and over:

  • an authenticated route loads a row by bare id with no tenant filter, so any user of tenant A can read tenant B's data by guessing an id;
  • a new SECURITY DEFINER Postgres function is left callable by anon because Postgres grants EXECUTE to PUBLIC by default;
  • two migrations collide on a number and apply in the wrong order.

These aren't exotic. Under CVE-2025-48757, 303 endpoints across 170 Lovable projects had Supabase tables readable by unauthenticated requests using the public anon key — the exact class tenant-guard anon-reads proves against. Wiz found an auth bypass in Base44 that reached into private enterprise apps. Veracode's 2025 study found 45% of LLM-generated code introduces an OWASP Top 10 flaw — and larger, newer models were not safer.

What it catches (run against a typical vibe-coded app)

tenant-guard  — guard tests for multi-tenant isolation

✓ migration-collisions — 3 migrations scanned; 0 grandfathered duplicate(s) ignored
✗ definer-grants — 1 unsafe function(s)
    • 200_add_reset_helper.sql
      function "reset_workspace" is SECURITY DEFINER + mutates but EXECUTE is not revoked from PUBLIC/anon
      → In the SAME migration add:  REVOKE EXECUTE ON FUNCTION public.reset_workspace(<args>) FROM PUBLIC, anon;
            If it is intentionally pre-auth, add "reset_workspace" to definerGrants.allowlist[] in your tenant-guard config.
✗ route-org-scoping — 1 route(s) can leak across tenants
    • src/app/api/invoices/[id]/route.ts
      authenticated + filters by bare id + never scopes by a tenant column
      → Add the tenant column to every query in this route, e.g. .eq('organization_id', auth.organizationId).
            If this route is genuinely tenant-agnostic, add "src/app/api/invoices/[id]/route.ts" to routeOrgScoping.allowlist[] with a one-line justification.
✗ updatable-view-writethrough — 1 view(s) write through to their base table, bypassing its RLS
    • 202_public_profiles.sql
      view "public_profiles" is auto-updatable (one relation, no aggregation) and runs as its OWNER — security_invoker is not set — so INSERT/UPDATE/DELETE pass straight through to "users", with that table's RLS evaluated as the owner rather than the caller. No REVOKE of INSERT/UPDATE/DELETE appears in these migrations, and this looks like a Supabase project (it references anon/authenticated), where default privileges grant writes on every new object in public. Granting only SELECT does not make a view read-only: the write privileges arrive on their own.
      → REVOKE INSERT, UPDATE, DELETE ON public_profiles FROM anon, authenticated;
            A view that exists to expose safe columns should be readable only. If writes ARE intended,
            make them go through checks: CREATE VIEW … WITH (security_invoker = true), or an INSTEAD OF trigger.
            If this view is deliberately writable, add "public_profiles" to updatableViews.allowlist[] with a reason.

✗ 3 guard(s) failed  (4 ran, 0 skipped)
  A guard fails your build so the leak never merges. Fix it, or allowlist it with a reason.

(That output is real — it's examples/leaky-demo/. Reproduce it: cd examples/leaky-demo && node ../../bin/tenant-guard.mjs run.)

Every one of these fails silently

That is the thing they have in common, and it is why a passing test suite is not evidence. An isolation bug does not throw. It returns fewer rows.

  • RLS denies a read → 0 rows, which is indistinguishable from "no data yet".
  • A PostgREST embed is denied → null, which renders as a blank name.
  • A trigger's uniqueness check cannot see the colliding row → it passes, and the duplicate is written.
  • A policy is too tight → the app writes a row and then cannot read it back. Nothing errors; the page just renders empty.

None of that shows up in an error log, and some of it gets worse as you harden the database: lock a table down correctly and the trigger that guarded it silently stops guarding. Reproduced exactly that way — in invoker mode the duplicate row was inserted, and the identical trigger marked SECURITY DEFINER raised.

So the guards are built to make the silence loud, and to be honest about which kind of statement each one is making:

  • A skip is never a pass. No database, no pg, no second tenant to compare against — each says so, by name, instead of reporting green.
  • "Not proven" is its own verdict. RLS on with zero policies looks exactly like perfect isolation from the outside; it is reported as unfinished.
  • Both directions are checked. Isolation fails by leaking and by being too tight, and most tools only name the first.
  • Findings are proven, not inferred. Where a catalog fact could be read two ways, the guard performs the write in a rolled-back transaction and reports what actually happened — because a fix you apply blind has to be safe, and advice built on inference once caused an outage in a real database.
  • Every guard has to prove it CAN fail. test/negative-control.test.mjs builds a database broken in exactly the way each guard exists to detect and requires a violation, then builds the corrected version of that same schema and requires silence. A guard that returns green on its own worst case is not a guard, however green its other tests are. Suggested by u/Guidondor, whose own RLS smoke test sat green for weeks because Postgres was raising a type error on the line that built the failure message — a suite that can't fail looks exactly like a suite that passes, and you can't tell them apart from the outside.

The guards

Static — read files on disk, zero dependencies, no database:

| Guard | Fails when… | Why a scanner misses it | |---|---|---| | route-org-scoping | an authenticated route filters by a bare id and never mentions a tenant column | catches the shape of the IDOR (auth + bare-id + no-tenant), and it lives in your CI so it blocks the merge instead of adding one more report | | definer-grants | a mutating SECURITY DEFINER function's final definition isn't revoked from PUBLIC/anon | requires knowing Postgres default grants + PostgREST exposure interact — revoking from anon alone is a no-op; judged on the net state of history, so a fix in a later repair migration counts | | updatable-view-writethrough | a view built to expose SAFE columns is also writable through to its base table | three ordinary defaults collide and nobody writes any of them down: a view over one relation with no aggregation is auto-updatable, so Postgres passes INSERT/UPDATE/DELETE to the base table; security_invoker is off by default, so those writes run as the view's owner and the base table's RLS never applies to the caller; and Supabase's default privileges grant anon/authenticated writes on every new object, so GRANT SELECT does not make it read-only. Reported from production, where DELETE /rest/v1/public_profiles with the public anon key wiped the users table. view-isolation cannot see it — that proves READ isolation, and this is a write | | migration-collisions | two migrations share a numeric prefix | a project-specific CI invariant (your numbering scheme), not a code smell |

Runtime — the core proof, against a seeded test database:

| Guard | Fails when… | Why a scanner misses it | |---|---|---| | rls-proof | a tenant's session can actually read or write another tenant's rows | it isn't reading source at all — it runs real queries as your app role (SELECT, plus UPDATE/DELETE/tenant-hop/INSERT/omitted-tenant probes) and measures the leak; nothing static can prove isolation holds, and RLS is per-command so reads passing says nothing about writes. Also runs a positive control: isolation has two failure modes and most tools name one. A policy that is too TIGHT never leaks and still breaks the product — so the guard inserts a row belonging to the acting tenant and checks it can read it back. Conclusive, because the database accepted the row: if a table it CAN read does not show it, the read policy is narrower than the write policy. | | rls-drift | the database has RLS enabled or a policy that no migration declares | catches security posture applied by hand in the dashboard/psql — invisible to code review, absent from CI, changeable with no diff or history |

Runtime — who can reach your data:

| Guard | Fails when… | Why a scanner misses it | |---|---|---| | anon-reads | the anonymous role can SELECT a tenant table, view, or materialized view | the CVE-2025-48757 class — the public anon key reads every tenant's data with no login; scoped to objects with a tenant column so public content isn't flagged, and it probes as anon so it evaluates the real policy, not just the grant | | anon-writes | the anonymous role can INSERT/UPDATE/DELETE a table | a table with no tenant column, writable by anon, is neither a tenant leak nor drift — it's the cache-poisoning class; it proves the real USING/WITH CHECK by probing, so it doesn't false-flag TO public USING (auth.uid()…) policies. Covers views as well as tables, at runtime: a view has no RLS of its own, and unless it was declared security_invoker = true it runs as its owner, so a write through it reaches the base table with that table's policies never consulted. ALTER DEFAULT PRIVILEGES ... ON TABLES arms every view created afterwards, which is why nothing in the migrations reads like a security change. Selected from the catalog (writes must actually pass through — an aggregate or join view carries the same grant and passes nothing) and then proven by probe. | | definer-rpc | a SECURITY DEFINER RPC hands out another tenant's rows, or lets a caller inject SQL that runs as its owner | the purest form of "the policy exists but the access path around it doesn't": a definer function runs as its owner, so it bypasses RLS on everything it touches, and PostgREST exposes it at /rest/v1/rpc/<name>. A function that doesn't re-filter by tenant — or that trusts a tenant id the caller passes in — routes around flawless policies, and every table-level check still reports green. It also reads the body for SQL injection (a parameter concatenated into EXECUTE, or passed through format()'s %s) — injected SQL runs as the owner, so it bypasses RLS wholesale — and for an unpinned search_path. Only STABLE/IMMUTABLE functions are ever called: Postgres guarantees those cannot write. Also catches the pin that isn't one: SET search_path = public, app reads as pinned and protects nothing if public is writable — verified, the function returned a planted table. pg_catalog first is what makes it a pin. | | identity-trust | the caller can forge the identity your policies authorize from, or write the thing that grants it | every other guard asks "given a correct identity, is the data scoped?" — this asks whether the identity itself is controllable. A policy reading user_metadata (which the user can rewrite) is defeated by forging that claim; a callable SECURITY DEFINER that sets your tenant GUC from an argument is a "become any tenant" primitive; a memberships table the caller can write makes a flawless policy bypassable; and — the one most apps have — a user can make themselves an admin: because RLS is row-level and cannot restrict columns, the textbook-correct USING (id = auth.uid()) WITH CHECK (id = auth.uid()) self-update policy pins which rows you may touch and says nothing about which columns, so update profiles set is_admin = true where id = me succeeds. Checked on every table with an update policy — including when the flag is read by a policy on that same table, or by nothing in the database at all because your application checks it. A column-level GRANT is the only real fix, and the guard recognises one as such | | pooler-bleed | the tenant identity outlives the request that set it | the only guard that has to read the database and your source, which is why this one goes unnoticed: the catalog says which custom GUC your policies authorize from, the source says whether you set it with is_local = false or a bare SET — which lasts for the whole connection. On a pooled connection the next request inherits the previous tenant and the policy hands over their rows working exactly as designed. Run one request and isolation is perfect, which is why every other check here passes: the leak exists only between requests |

Runtime — the surfaces that aren't base tables:

| Guard | Fails when… | Why a scanner misses it | |---|---|---| | view-isolation | a view or materialized view leaks across tenants | a view runs with its owner's rights unless security_invoker is set, and RLS never applies to a materialized view at all — so a perfectly-RLS'd table can still be handed out wholesale by the view beside it, and every table-only checker (including rls-proof) sees nothing wrong | | storage-isolation | Supabase Storage leaks across tenant folders | storage has no tenant column — tenancy lives in the object path. Two things follow that a column-based check cannot see: the client picks the path on upload, so a perfect read policy still lets a user write into another tenant's folder; and a public bucket is served with no auth and no RLS at all, making "the path is unguessable" the whole boundary | | realtime-isolation | Supabase Realtime channels leak across tenants | Realtime is a second way out of the database, easy to forget once REST looks locked down. Broadcast and Presence authorize channels through RLS on realtime.messages — with none, any client joins any tenant's channel, reads every payload on it, and (since joining is a write) publishes into it. The tenant lives in the topic, not a column |

Runtime — where tenant data goes, and what else the role can reach:

| Guard | Fails when… | Why a scanner misses it | |---|---|---| | shadow-tables | a trigger copies tenant data into a table nothing protects | the source has flawless RLS; the audit log, outbox or cache it feeds usually has no tenant column at all, so every tenant-aware check walks past it. Verified: the source returns one row to its tenant while the shadow returns both tenants', and rls-proof reports green | | role-capabilities | the app role holds a capability that defeats RLS, or a direct grant on the auth schema | dblink opens a new connection as whatever role its string names — RLS on it has nothing to do with the caller's; file reads never touch the policy layer; and auth.users is every tenant's email in one table with no policy of yours in front of it. Outbound HTTP (pg_net) is surfaced as a note: real, but exfiltration rather than a cross-tenant read | | schema-tenancy | one role can read more than one tenant schema | the other multi-tenant architecture — a schema per tenant — where the boundary is GRANTs and nothing else. search_path is not a control: it doesn't stop anyone writing tenant_b.docs directly, and the client can reset it. Every column-based check is blind here: point rls-proof at such a database and it reports "1/1 proven isolated" while the app role reads both tenants | | default-privileges | a table created tomorrow arrives granted and unprotected | the only guard about the database as it will be. ALTER DEFAULT PRIVILEGES grants on every table created after it, and Postgres never enables RLS by default — so a green run says nothing about the table somebody adds next week: it arrives already granted, with no policy, exposed the instant it exists, and no migration diff shows a security change. It proves rather than infers, by creating a table inside a rolled-back transaction and reading what that table actually inherited | | cross-tenant-fk | a foreign key lets one tenant reach — and destroy — another tenant's rows | the only finding here where a tenant deletes another tenant's data rather than reading it. Referential integrity checks always bypass RLS — they must, or a constraint could be defeated by hiding a row. So a key carrying an id but not the tenant lets tenant A point their row at tenant B's (the FK confirms a row A cannot see; the WITH CHECK governs the tenant column, not the reference), and then ON DELETE CASCADE means B deleting their own row deletes A's. Verified end to end, on a database rls-proof calls isolated | | create-grants | someone who is not your migration role can plant objects in your database | CREATE is the quietest privilege in Postgres — nothing about it looks like data access — and it is the precondition that turns an unpinned SECURITY DEFINER search_path into escalation: you can only shadow an object if you can create one. That is CVE-2018-1058's shape, and why Postgres 15 stopped granting it on public to PUBLIC. Reported even when no definer function exists yet, because the grant arms the next one somebody writes — the one state a function-by-function check has nothing to evaluate in | | mfa-enforcement | your second factor gates the login screen, not the data | PostgREST honours whatever JWT the client presents, so the only thing that can refuse a single-factor token at the data layer is a policy checking the assurance level. And an aal2 check written PERMISSIVELY enforces nothing — Postgres ORs permissive policies and ANDs restrictive ones, so it can only widen access. Verified: with a permissive gate an aal1 session reads every row; with AS RESTRICTIVE it reads none. The policy reads exactly right and does nothing. Also flags factors enrolled with no aal policy anywhere, and enforcement that covers only some tenant tables | | trigger-visibility | a trigger enforces a rule by reading a table RLS hides from it | a trigger function runs as the INVOKER unless declared SECURITY DEFINER, so a uniqueness check inside one sees only the rows RLS shows the writing role. The day someone locks that table down — the correct thing to do — the check stops finding collisions and silently starts passing. Verified, and worse than a missed check: in invoker mode the duplicate row was inserted; the identical trigger marked SECURITY DEFINER raised. Requires an enforcement signal (RAISE / RETURN NULL) and a measured visibility gap, so it stays off every set_updated_at in the schema | | column-exposure | a sensitive column is served to the anonymous internet, on the table no tenancy guard covers | the waitlist, the author directory, the api_clients lookup — no tenant column, so anon-reads and rls-proof are silent on them correctly: nothing leaks across tenants because there are none. It leaks to everyone. Proven by reading, never inferred from a grant — measured, a single table-level GRANT SELECT expands to every column in information_schema.column_privileges, so a grant-based check reported a fully-isolated table as exposing all seven of its columns and fired on a table RLS already closes. This one fails only when anon gets a real non-null value back, and the probe is a count() so the finding never carries the leaked value into your CI log |

Catalog — the schema's shape rather than its behaviour:

| Guard | Fails when… | Why a scanner misses it | |---|---|---| | constraint-oracles | a constraint answers questions across tenants | RLS hides rows, not constraints — and constraints are enforced below it. users.email UNIQUE on a tenant table means inserting [email protected] raises a duplicate-key error even though RLS hides the row that caused it, so anyone can test whether a value exists in another tenant (and ON CONFLICT DO NOTHING asks the same question with no error at all). Nothing about the policies is wrong; the schema is the leak |

Which databases this is for

PostgreSQL, and anything that speaks it. The runtime guards work by dropping to your app role and proving row-level security actually holds — so they need an engine that has RLS, and a pg_catalog to introspect.

| | Status | |---|---| | Supabase | Fully supported, and the Supabase-only surfaces (Storage, Realtime, the auth schema) have guards of their own | | Neon, RDS/Aurora, Cloud SQL, Timescale, self-hosted Postgres | Fully supported. Nothing is Supabase-specific except the guards that say so, and those skip cleanly with a stated reason rather than failing you | | CockroachDB | Untested. It speaks the Postgres wire protocol and has Postgres-compatible RLS from v25.2, so this may work as-is — if you try it, a report either way is welcome | | MySQL, MariaDB, PlanetScale, SQLite, MongoDB | Not supported, and not planned. These have no row-level security, so there is no policy layer to prove. Isolation there is enforced in application code or by schema/database separation — a genuinely different check, not a port of this one | | SQL Server, Oracle | Not supported. Both have real RLS (predicate functions, VPD), but a different driver, catalog and impersonation model — that's a rewrite sharing a name, not an extension |

If your tenancy is one schema per tenant rather than a tenant column, that's covered too — see tenant-guard schemas. It's the usual pattern on engines without RLS, and it's common on Postgres as well.

A non-Supabase run looks like this — real guards doing real work, and the Supabase-only ones stepping aside:

rls-proof            ok   | 1/1 tenant table(s) proven isolated (read + write)
anon-reads           ok   | 1 tenant table(s) checked; none readable by "app_user"
identity-trust       ok   | 1 tenant policy/policies checked; identity sources look unforgeable
storage-isolation    ok   | skipped — no storage schema
realtime-isolation   ok   | skipped — no realtime schema

How it fits your project

tenant-guard runs inside your repository, against the files already on disk. It is not a scanner you point at a URL or a website, and there is no hosted service — you run it where your code is (locally or in CI). The only thing you ever "point it at" is a test database connection string, and only for the runtime guards.

| Part | What it reads | What you provide | |---|---|---| | static guards (run) | your API-route and SQL-migration files in the current directory | nothing — it reads the folder | | runtime guards (prove, drift, anon-*, identity, views, storage, realtime, oracles) | a live Postgres database | a connection string to a test/staging DB (never a URL, never prod) | | everything (all) | both of the above, in order | as above — anything without a database skips, and a skip is never a pass |

Three places it runs, all inside the repo:

  • Locallycd your-project && npx tenant-guard run scans that folder.
  • In CI — your pipeline checks out the repo and runs npx tenant-guard run (or all, with a test database), so a leak blocks the merge.
  • In your test suiteimport { runAll, prove } from 'tenant-guard' and assert no violations, so it runs with npm test.

Anyone else uses it identically in their own repo: npx pulls it from npm (no clone of this repo, no account, no server), init autodetects their layout, and they commit a tenant-guard.config.json so their team and CI share one baseline. It works on any stack with API-route files and SQL migrations — see Config to point the signals at a non–Next.js/Supabase layout.

It does not take a repo URL, crawl GitHub, make HTTP requests to your app, or run as a SaaS.

Prove it at runtime — the part no scanner can do

The static guards read source text: they catch the obvious leak cheaply, but they can't prove isolation holds. tenant-guard prove can. Against a seeded test database it:

  1. finds every table with a tenant column — including partitioned tables and each of their partitions — noting whether RLS is even on;
  2. as the privileged role (which bypasses RLS, like Supabase service_role) picks two real tenant ids that already have data;
  3. drops to your non-superuser app role (e.g. authenticated), assumes tenant A's identity, and asserts A's session can neither read nor write tenant B's rows — SELECT, plus the full write path: UPDATE/DELETE of B's rows, a tenant-hop (reassigning A's own row into B), an INSERT that creates a row in B, and an omitted-tenant INSERT (tenant column NULL) that creates an orphan row every tenant can read — then checks the other direction.

If RLS is off, a policy is USING (true), a policy forgot the tenant predicate, the write path is unprotected (RLS is per-command — a correct SELECT policy does not cover UPDATE/DELETE, and USING does not cover INSERT or where an update lands; those need WITH CHECK), or RLS is on with no policy at all (a deny-all that only looks isolated), the proof names it and fails your build.

Before trusting any pass it runs a negative control: it drops to your app role and asserts that role cannot read a deliberately deny-all table. If it can, RLS isn't being enforced for it — a superuser, a BYPASSRLS role, a table owner — so every "isolated" result would be a vacuous pass, and the run fails instead of reporting one. A check you can't falsify is worthless.

See it catch a real leak with zero infrastructure:

npm i @electric-sql/pglite        # embedded Postgres — no Docker
node examples/rls-proof/demo.mjs  # passes a good policy, fails a leaky one

Against your own database (a test/staging one, never production):

npm i -D pg
export TENANT_GUARD_DATABASE_URL="postgres://…/your_test_db"
npx tenant-guard prove

Every write probe runs inside a SAVEPOINT that is rolled back, and the whole run is one transaction that is rolled back — non-destructive by construction (set probeWrites: false to test reads only). A skip (no database, or pg not installed) is not a pass, and the CLI says so. Full setup — including the Supabase JWT-claim config and seeding mode for an empty CI database — is in examples/rls-proof/.

The rest of the surface

Each of these is one command, inherits the same identity config, and skips cleanly when it doesn't apply to your stack.

drift — prove your RLS is in version control. RLS can be enabled and given policies by hand in the Supabase dashboard and never captured in a migration. When that happens the table's real posture is invisible to code review, absent from every fresh/CI database, and editable in the UI with no diff and no history. A permissive policy letting anon write a shared table can live in production for months without appearing in a single pull request. (Not hypothetical — it's how a real cache-poisoning bug hid in a Supabase app we ran this against.) drift diffs every ENABLE ROW LEVEL SECURITY / CREATE POLICY in your migrations (net of DROP/DISABLE) against the live catalog; anything in the database that no migration declares fails the build.

anon-reads / anon-writes — the unauthenticated surface. The public anon key ships in every browser bundle. anon-reads proves it can't SELECT tenant data (the CVE-2025-48757 class); anon-writes proves it can't write a shared table (the cache-poisoning class). Reliability is the point: well-secured Supabase apps write TO public USING (auth.uid() = …), which a catalog-only check can't evaluate and would false-flag — so both probe as anon and evaluate the real USING/WITH CHECK rather than guessing from grants.

identity — can the caller forge the identity itself? Everything above assumes the identity is honest. This asks whether it is: a policy authorizing from user_metadata (user-writable, unlike app_metadata) is proven forgeable by forging exactly that claim; a callable SECURITY DEFINER that sets your tenant GUC from an argument is a "become any tenant" primitive; a membership table you can write makes a correct policy bypassable; and a self-update policy still lets you rewrite the role column that grants your access, because RLS cannot restrict columns — only a column-level GRANT can.

That last one is worth spelling out, because it is the one most applications have and the one that looks correct in review:

-- The policy is right. Everyone reviews this and moves on.
CREATE POLICY self ON profiles FOR UPDATE
  USING (id = auth.uid()) WITH CHECK (id = auth.uid());

-- And then:
UPDATE profiles SET is_admin = true WHERE id = auth.uid();   -- succeeds

RLS decides which rows you may touch. It has nothing to say about which columns, so a policy that correctly confines you to your own row does not stop you rewriting the field that grants your access. The guard checks every table carrying an update policy — including when the flag is read by a policy on that same table, or by nothing in the database at all because your application is what checks it — and grades accordingly: a tenant column or a column some policy reads fails the build; a column merely named like an authorization field (is_admin, role, plan, …) that no policy reads is a note, since SQL cannot see whether your app treats it as one. Add your own names with identityTrust.authorizationColumns. The fix it gives is the only one that works:

REVOKE UPDATE ON profiles FROM authenticated;
GRANT UPDATE (display_name, avatar_url) ON profiles TO authenticated;

and once that grant is in place the guard goes quiet, because the column really is unwritable.

views, storage, realtime — the surfaces that aren't base tables. A view runs with its owner's rights unless security_invoker is set; a materialized view ignores RLS entirely; Storage and Realtime key tenancy off an object path and a channel topic rather than a column, and in both the client chooses that string when it writes. Each is invisible to a table-only check — there is a test asserting rls-proof passes while view-isolation fails on the same database.

rpc — the function that runs as its owner. A SECURITY DEFINER function bypasses RLS on everything it touches and is exposed by PostgREST as an endpoint. If it doesn't re-filter by an auth-derived tenant — or filters by a tenant id the caller supplies — the policies on the underlying tables never get a say. This is also where the tool is most careful about its own blast radius: it will only call a function Postgres has classified STABLE or IMMUTABLE, because the engine enforces that those cannot write. A VOLATILE definer function is never invoked — it is reported from a read of its body, as a note, saying exactly that.

shadows and caps — where the data goes, and what else the role holds. A trigger on a protected table writes to an audit log that nothing protects, and the data is out. Separately, some grants make every table-level question moot: dblink opens a connection RLS knows nothing about, and a direct grant on auth.users hands over every tenant's email.

oracles — the schema itself. RLS hides rows, not constraints. A globally UNIQUE natural key on a tenant table lets anyone test whether a value exists in another tenant, through an error message they were never meant to see.

Why not just a SAST scanner?

There is a good static scanner for this space — mcp-tenant-isolation (57 rules, SARIF). If you want a report of findings, use it; the two compose.

tenant-guard is a different tool for a different failure mode. A scanner produces a list of findings that a busy team learns to ignore (alert fatigue). tenant-guard's checks are tests in your own suite — red in npm test, the PR can't merge, and they carry a per-project allowlist: when you adopt it on a legacy repo you allowlist the debt you can't fix today, the build goes green, and it can only get better from there. It's the ArchUnit philosophy — architecture rules as executable tests — pointed at tenant isolation, which nobody had done.

Adopt it

GitHub Actions — one step, no config, no install:

# .github/workflows/tenant-guard.yml
name: tenant-guard
on: [pull_request]

jobs:
  guard:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write     # so findings reach the Security tab
    steps:
      - uses: actions/checkout@v4
      - uses: FedericoTs/tenant-guard@v0

That runs the static guards, blocks the merge on a finding, writes a result table to the run summary page, and annotates each finding on the pull-request diff. Add a database to turn on the runtime proofs:

      - uses: FedericoTs/tenant-guard@v0
        with:
          command: all
          database-url: ${{ secrets.TEST_DATABASE_URL }}

Never point the runtime guards at production. They write — inside a rolled-back transaction, but they write. Use a seeded test or staging database.

Locally, or on any other CI:

npx tenant-guard init          # writes tenant-guard.config.json
npx tenant-guard run           # allowlist any legacy finding you can't fix yet
npx tenant-guard all --json    # results as data, for anything downstream

Adopting on a codebase that will light up on day one? Set fail-on-error: false so findings appear without blocking, or allowlist the debt you can't fix today — from then on the guard can only ratchet in your favour.

📘 docs/CI.md — inputs and outputs, a Postgres service container, seeding two tenants on an empty CI database, GitLab and other CI, troubleshooting. 📗 docs/OUTPUT.md--json, --sarif, --markdown, and the exit-code contract.

Or import the guards into your existing vitest/jest suite:

import { runAll } from 'tenant-guard';
test('no cross-tenant leaks (static)', () => {
  const failed = runAll().filter(r => !r.ok && !r.skipped);
  expect(failed).toEqual([]);
});

The runtime guards drop straight into your suite too — hand any of them a Postgres client whose query(text, values) returns { rows } (node-postgres, or an embedded pglite in tests):

import { prove, checkViews, checkIdentity } from 'tenant-guard';
test('RLS actually isolates tenants', async () => {
  const res = await prove({ query: (t, v) => pool.query(t, v) });
  expect(res.violations).toEqual([]);
});

Config

tenant-guard.config.json (see examples/tenant-guard.config.json for the annotated version, or run tenant-guard init to generate it). Every guard is opt-in and autodetects its paths; a guard that doesn't apply to your stack skips, it never fails you.

{
  "migrations":        { "dir": "supabase/migrations", "grandfather": ["031", "101"] },
  "definerGrants":     { "baseline": 189, "allowlist": ["validate_public_token"] },
  "routeOrgScoping":   { "routesDir": "src/app/api", "allowlist": [] },
  "rlsProof":          { "role": "authenticated", "claim": "org_id", "tenantColumns": ["organization_id"], "grandfather": ["shared_lookup"] },
  "rlsDrift":          { "schemas": ["public"], "allowlist": ["public.some_supabase_managed_table"] },
  "anonReads":         { "role": "anon", "allowlist": ["public.published_posts"] },
  "anonWrites":        { "role": "anon", "allowlist": [] },
  "identityTrust":     { "allowlist": [] },
  "viewIsolation":     { "allowlist": ["public.admin_reporting_view"] },
  "storageIsolation":  { "pathSegment": 1, "allowlist": ["brand-assets"] },
  "realtimeIsolation": { "topicSeparator": ":", "allowlist": [] },
  "constraintOracles": { "allowlist": ["public.orgs"] }
}

The runtime guards run only when TENANT_GUARD_DATABASE_URL (or DATABASE_URL) is set, so they stay skipped until you opt in. Identity is configured once: viewIsolation, identityTrust, storageIsolation and realtimeIsolation inherit role / becomeTenant / claim from rlsProof unless you override them.

Assuming a tenant's identity. becomeTenant defaults to the canonical Postgres GUC pattern. For Supabase JWT policies the claim shortcut is usually all you need — "claim": "org_id" builds the request.jwt.claims impersonation and sets role to authenticated, so CI never needs your JWT secret. Apps that resolve the tenant through a memberships table use an explicit becomeTenant (arbitrary SQL) plus rlsProof.seed, which manufactures two synthetic tenants inside the rolled-back transaction so the proof works on an empty CI database. Details in examples/rls-proof/.

Per-user apps (the tenant is a user, not an org). The defaults key off organization_id/tenant_id and deliberately don't treat user_id as a tenant column — in a B2B app user_id is often just the creator, and treating it as the tenant boundary would hide real org leaks. If your isolation boundary is the user, add it to both signals:

{
  "routeOrgScoping": { "tenantSignals": ["user_id", "organization_id"] },
  "rlsProof":        { "tenantColumns": ["user_id", "organization_id"] }
}

You probably no longer need the routeOrgScoping half. A route filtering by the session user's own id — .eq('user_id', user.id), .eq('id', user.id) — is recognised as a per-user scope on its own since 0.41.0, because the guard reads the filter's value and not just its column name. Adding user_id to tenantSignals also silences .eq('user_id', params.userId), which is an IDOR — verified — so reach for it only if your session value is spelled in a way the defaults miss, and prefer sessionValueSignals for that:

{ "routeOrgScoping": { "sessionValueSignals": ["ctx.viewer.id"] } }

Then allowlist genuinely shared tables/routes — a Google-Places cache, a public reference table — with a one-line reason, rather than scoping data that is supposed to be global.

Defaults target Next.js App Router + Supabase/Postgres, but every signal (auth helpers, tenant column names, route glob) is configurable, so it works on any stack that has API routes and SQL migrations. The bare-id detector ships with patterns for Supabase (.eq('id')), Prisma (where: { id }), and Drizzle (eq(table.id, …)) out of the box; raw SQL (where id = …) is left out of the default to avoid false positives on self-loads — widen idFilterPattern in config if you want it. These claims are backed by tests in test/flexibility.test.mjs, which also prove rls-proof against Supabase JWT-claim policies and non-Supabase session-GUC apps.

Honest limits

The full map is THREAT-MODEL.md — every way tenant isolation is known to break, each tagged covered / partial / out-of-scope, with why for the ones that are out of scope. It is the coverage target this project builds against, and the honest statement of what a green run does and doesn't prove. Read it before trusting one.

The static guards are heuristics on source text, deliberately conservative: they catch a bug shape, not every instance. The real defence is row-level security enforced in the database, which is exactly why the runtime guards exist — they don't guess from source, they run queries as your app role and measure whether isolation actually holds.

The runtime guards have limits of their own, and report them rather than papering over them:

  • They prove isolation for what they can reach with the tenant identity you configure. A mismatch between becomeTenant and your real policies shows up as "sees none of its own rows either"not as a false pass.
  • A table holding one tenant's data can often still be proven, by impersonating a tenant that exists elsewhere. When it genuinely can't be, it is reported as not proven, never as passing.
  • Some findings are deliberately notes rather than build failures — a client-settable tenant GUC, TRUNCATE privilege, single-column foreign keys — because whether they are exploitable depends on architecture SQL cannot see. Failing the build on an unfalsifiable finding is how a security tool becomes ignorable.

And four classes are out of scope by construction, because "run SQL as the app role" cannot see them. They need a different instrument, and they stay listed in the threat model rather than being quietly omitted:

  • App-layer IDOR through a service-role/admin connection that forgets .eq('organization_id', …). The database serves it correctly; the bug is in the application. (route-org-scoping covers the shape of this, statically.)
  • A leaked service_role key in a client bundle — needs a secret scan.
  • Connection-pooler state bleed (SET instead of SET LOCAL on a transaction-pooled connection) — invisible from inside a single session.
  • A weak or leaked JWT secret — key management, not RLS.

It is a strong proof on every commit, not a substitute for a pen test.

Background

These guards were extracted from a production multi-tenant EU SaaS (now retired) where they ran green on every push. The interesting finding from that codebase is written up in METHODOLOGY.md: the descriptive docs written for the AI agent all rotted, while every executable guard survived.

Since then the project stopped growing one reported bug at a time and started building down THREAT-MODEL.md instead — which promptly surfaced a false negative in the flagship guard (partitioned tables reporting green while leaking) that no amount of waiting for bug reports had found.

Documentation

| | | |---|---| | docs/CI.md | Wiring it into CI — the GitHub Action's inputs and outputs, a Postgres service container, seeding two tenants on an empty CI database, GitLab and other CI, troubleshooting | | docs/OUTPUT.md | --json, --sarif, --markdown, the exit-code contract, and programmatic use | | THREAT-MODEL.md | Every way tenant isolation breaks, what is covered, and what this method cannot see | | METHODOLOGY.md | Where these guards came from, and why the executable ones outlived the written ones | | examples/ | An annotated config, a CI workflow, a runtime-proof walkthrough, and leaky-demo — a repo that fails on purpose |

Licence

MIT — see LICENSE. By Federico Sciuca.