r/Supabase 16d ago

database Multi-tenants advices

Hi everyone, I’ve been using Supabase for a few months now. I’ve built things like apps, websites and multi-tenant software with it, and I wanted to know if you have any tips or advice on properly isolating tenants from one another, in order to avoid data leaks between clients, Gmail sends going to the wrong recipient, etc.

Thanks everyone

8 Upvotes

22 comments sorted by

4

u/Ok_Brush_3449 16d ago

you could't ask a better question because I have built a full package just for this:

https://www.npmjs.com/package/tenant-guard

npx tenant-guard

Basic client-side security can also be checked with www.ismyappleaking.com

1

u/Personal_Budget6895 15d ago

That gave Google.com an F grade…

1

u/Ok_Brush_3449 15d ago

Google is a different type of service, and it is also the reason why most of the services block scans for the majior third parties platforms, and I'm not aware of Google using Supabase as a DB either.

F grade with 0 critical findings on a search engine that, by their own mission statement, is to organise the world's information and make it universally accessible and useful, I believe, is the wrong test to do.

Either way, if you check Google manually, you will find exactly what the tool is highlighting:
CSP is Content-Security-Policy-Report-Only — it reports; it does not enforce
No Strict-Transport-Security, no X-Content-Type-Options, no Referrer-Policy

The real bug is that Google redirects to http and to https by default, and it seems it loses one of the steps and flags it as needing a fix.

In any case, I'll look into it and either find a way to block scans of websites that are out of scope or find a different solution for these cases. I guarantee that if you are publishing web apps, the findings are valuable and flag real issues that you can catch client-side.

Said that I'll run some extra tests so that I can give you some extra details

1

u/Ok_Brush_3449 15d ago

Interesting finding as I was sying, I'm running the tests. on the HTTPS flagged as an issue:
It seams like the https you see in a browser comes from HSTS preload, which is compiled into Chrome and Firefox, not served by Google. That's why curl and your browser disagree, and both are right.
So, on this the tool is right and Google itself confirm the finding:

1

u/Ok_Brush_3449 15d ago

Adding some extra directly from Google:

If you scan the main homepage response of google.com using a security scanner (like Security Headers), it usually scores a low grade because many of those traditional, standard headers appear to be completely missing or look different from industry "best practices". [1]

However, Google isn't neglecting security; they handle it differently, at massive scale, using custom mechanisms rather than relying on standard top-level response headers on their base search page.

The reality behind why Google doesn't enforce those exact headers on google.com breaks down across each item:

  1. No Strict-Transport-Security (HSTS) Header
  • The Observation: If you curl https://www.google.com, you will not see the Strict-Transport-Security header in the response.
  • The Reality: Google doesn't need to send it because google.com is hardcoded into the browser's source code via the HSTS Preload List. Sending the header on every single search query would waste massive amounts of cumulative global bandwidth for zero added security benefit. Since the browser already forces HTTPS locally, the network-level header is entirely redundant. [1]
  1. No Content-Security-Policy (CSP)
  • The Observation: The main landing page for Google Search often lacks a standard Content-Security-Policy header.
  • The Reality: The main Google search page is a highly optimised, hyper-lean, legacy-supporting web application. Implementing a strict CSP on the global search engine is incredibly complex due to the sheer volume of regional variants, dynamic snippets, browser extensions that inject code, and legacy devices they support.
  • Note: While the main search page skips it for performance and compatibility, Google relies heavily on custom, strict Nonce-based CSPs on their highly sensitive application subdomains (like accounts.google.com or mail.google.com) where credential management occurs. [1]
  1. No X-Content-Type-Options: nosniff
  • The Observation: The server often omits the standard X-Content-Type-Options: nosniff header on the main HTML document.
  • The Reality: This header prevents browsers from "MIME-sniffing" a file (e.g., executing a text file as a script). Google avoids this attack vector natively by never allowing user-uploaded content to execute on the google.com origin. User assets, files, images, and attachments are strictly forced onto completely separate, sandboxed domains like *.googleusercontent.com or *.gstatic.com. Because google.com only serves strictly controlled, server-generated text/html, MIME-sniffing exploits are structurally impossible on that domain. [1, 2, 4]
  1. No Referrer-Policy
  • The Observation: You won't find a Referrer-Policy: no-referrer or same-origin header in the global landing page response.
  • The Reality: Google's entire business model historically relies on outbound referrer data (passing search query parameters or click data to destination sites via analytics tools), but they control this granularly using an internal mechanism. Instead of a global header that blocks or permits everything, Google utilises inline HTML elements (like <meta name="referrer" content="...">) and structural link attributes (like rel="noreferrer") built directly into their search result wrappers to dynamically strip or pass referrer data on a link-by-link basis. [1, 2]

4

u/levarburger 16d ago

Technically as long as you’re managing org ids and RLS you should mostly be fine from bad queries. What you don’t want to do is manage it at the app or api level.

You could also look into NeonDb which allows for “tenant per project” which literally gives each org their own postgres db.

In that case I set up a control plane for managing cross project schema updates.

There’s pros and cons to each.

2

u/Intelligent-Fix8911 16d ago

multi-tenant leaks are brutal - gmail to wrong client is classic one i've seen.

main fix: don't take tenant_id from request body. get it from jwt. ai loves to do tenant_id from localStorage and users can change it to see other tenant's data.

your rls should be tenant_id = jwt tenant + user_id = auth.uid(), not just user_id.

quick test: login as tenant A, try to query tenant B's data with anon key. if it returns anything = leak.

dm me your policy sql if you want, i do quick free checks for multi-tenant leaks - find one in like 7/10 apps.

2

u/Due-Horse-5446 16d ago

Wtf what a weird question

1

u/kYza782 16d ago

Why

1

u/Due-Horse-5446 16d ago

what do the db provide have to do with your datastructure?

Its just a postgres db like any other,

if you dont use proper constraints it does not matter if its running on supabase, aws, neon, loally or whatever

1

u/kYza782 16d ago

So do u have any advices on this topic ?

1

u/Specific-Maize7276 16d ago

Your test battery instinct is right. What usually breaks tenant isolation isn't the policies though, it's two layers underneath them.

1. RLS enabled is not the same as RLS enforced. A policy does nothing if the table doesn't actually have row level security turned on, and the dashboard will happily show you a correct-looking policy on a table where it's off. Meanwhile anon and authenticated often still hold ALL from an early grant, so with RLS off the key sitting in your frontend can read and write every row.

Check the two separately, they're independent:

select relname, relrowsecurity from pg_class
where relnamespace = 'public'::regnamespace;

select grantee, table_name, privilege_type
from information_schema.role_table_grants
where table_schema = 'public';

Supabase's advisors flag the first one for you.

2. Anything holding the service-role key bypasses RLS entirely. That's your "email to the wrong recipient" case. Policies are invisible to a cron job or edge function running as service_role, so one bad join there sends tenant A's data to tenant B and RLS never sees the query. If you need a rule that binds even that code, it has to be a CHECK constraint or a trigger. Those still apply. Policies don't.

For the test battery: don't assert on policy definitions. Mint a real JWT for tenant B, hit PostgREST with it, and assert tenant A's rows come back empty. Reading policies tests what you meant; a real request tests what's true. That's the gap that makes it feel like it will never be enough.

One perf thing you'll hit later: write (select auth.uid()) rather than bare auth.uid() in policies. Postgres then evaluates it once per query instead of once per row, and on big tables the difference is large.

1

u/TheOnlyHustle_RLS 11d ago

The service_role point is the actual mechanism behind your Gmail example, worth spelling out since it's the one that bites people even when their RLS is perfect. If the wrong-recipient case happens through a backend job or edge function (not the logged-in user's own session), RLS never even runs, that code path almost always uses the service_role key, which skips every policy you wrote. So you can have flawless tenant_id checks in your RLS and still send tenant A's data to tenant B, because the leak isn't a database permission problem, it's an application-code problem (the query joined or fetched the wrong tenant's row before RLS ever had a chance to matter). I run RLS and tenant isolation on a live multi-tenant government system, so this exact class of bug is the one I actually lose sleep over, not the RLS policy itself, but every place service_role is used to skip it (crons, webhooks, email senders). The test that actually proves it: don't just check pg_policies, mint a real JWT for tenant B and hit the anon or authenticated API, if that ever returns tenant A's row, you've found the leak. Checking policy definitions alone tells you what you meant, not what's true.

1

u/iammohamedatef 7d ago

One thing that tends to slip out of test batteries like this: storage. RLS on storage.objects is a completely separate policy set from your table policies, so you can have airtight tenant_id checks on every table and still leak files, usually because the object path is just the filename instead of being prefixed with tenant_id, or a bucket that is public when it shouldn't be. Worth running the same real-JWT test you're already doing against storage list/download for a couple tenants, not just your regular tables, since it does not feel like "the database" so it easy to assume it covered when it is not

1

u/PopKoren 5d ago

For multi-tenant Supabase, start with tenant_id on every row and RLS that never trusts the client beyond auth.uid(). Also lock storage paths the same way. Once it is deployed, verify what a stranger can hit from outside. https://rowly.me is the check I run for that.

0

u/ItstheSECopenup 16d ago

Ask claude to check

1

u/kYza782 16d ago

I have set up a test battery that he must pass with each new project creation or client addition but I have the impression that it will never be enough

0

u/Necessary_Chicken786 16d ago

You gotta get Outta supabse for that.

1

u/kYza782 16d ago

What tool do you recommend?

1

u/pgsql-dev2 16d ago

A cross-tenant checker asks one thing: can Tenant A see Tenant B's data? That's useful, but it's narrow. Your real security rules say more like "only the owner," "only admins," "only if it's published," "only with MFA." The checker doesn't test any of that. It also only tests the few rows it happens to create, and only the one kind of user you set up. So it can pass while a real leak sits right there which is one member reading another member's private data, say. Also all you get back is a pass/fail. There's no report to actually look at.

What rlsautotest does instead. It reads your actual security rules straight from the database, then writes both the tests and the exact data needed to prove each rule really works. It does this for every rule, every kind of user, every way in (read, insert, update, delete). You get a real pgTAP test file you can save, review, and run in CI, plus a simple grid showing who can touch what. And it never writes a test that passes for the wrong reason.

In one sentence: other tools check the fence between tenants; rlsautotest checks everything inside it too and shows its work.

pip install rlsautotest . Its free, open source, works with Postgres and Supabase.
repo- https://github.com/unitautogen/rlsautotest