<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: André Wenceslau</title>
    <description>The latest articles on DEV Community by André Wenceslau (@wenceslaudev).</description>
    <link>https://dev.to/wenceslaudev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4047284%2Fe585662f-6b6e-42db-8087-3e69c1ce6dac.png</url>
      <title>DEV Community: André Wenceslau</title>
      <link>https://dev.to/wenceslaudev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wenceslaudev"/>
    <language>en</language>
    <item>
      <title>Postgres RLS multi-tenancy: two leaks that survive correct policies</title>
      <dc:creator>André Wenceslau</dc:creator>
      <pubDate>Wed, 19 Aug 2026 01:03:55 +0000</pubDate>
      <link>https://dev.to/wenceslaudev/postgres-rls-multi-tenancy-two-leaks-that-survive-correct-policies-kb2</link>
      <guid>https://dev.to/wenceslaudev/postgres-rls-multi-tenancy-two-leaks-that-survive-correct-policies-kb2</guid>
      <description>&lt;p&gt;I wrote &lt;a href="https://dev.to/wenceslaudev/postgres-rls-multi-tenancy-two-traps-that-silently-disable-your-policies-5gn8"&gt;an earlier post&lt;/a&gt;&lt;br&gt;
about two traps that silently switch Postgres row level security off:&lt;br&gt;
connecting as a role that is exempt from policies, and a transaction-local&lt;br&gt;
GUC that reverts to an empty string on a pooled connection.&lt;/p&gt;

&lt;p&gt;Both of those are failures of the policy layer. You fix them and the policies&lt;br&gt;
start doing their job.&lt;/p&gt;

&lt;p&gt;This post is about the harder category: &lt;strong&gt;two ways data crosses the tenant&lt;br&gt;
boundary while every policy is working exactly as written.&lt;/strong&gt; Both were&lt;br&gt;
raised by a reader named Rahul S in the comments on that post, both survive a&lt;br&gt;
correct two-role split, and neither goes through the read path — which is why&lt;br&gt;
you will not catch them by testing &lt;code&gt;SELECT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Assume you have done everything right. The app connects as a role that owns&lt;br&gt;
nothing and holds neither superuser nor &lt;code&gt;BYPASSRLS&lt;/code&gt;. Every tenant table has&lt;br&gt;
&lt;code&gt;ENABLE&lt;/code&gt; and &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;. Every policy has &lt;code&gt;USING&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;WITH CHECK&lt;/code&gt;, both wrapped in &lt;code&gt;NULLIF(current_setting(...), '')&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is what still gets out.&lt;/p&gt;


&lt;h2&gt;
  
  
  Leak 1: &lt;code&gt;SECURITY DEFINER&lt;/code&gt; hands the exemption straight back
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function runs with the privileges of the function's&lt;br&gt;
&lt;strong&gt;owner&lt;/strong&gt;, not the caller's. That is the entire point of the feature, and it&lt;br&gt;
is useful. It is also the role exemption from the first post, arriving through&lt;br&gt;
a door that does not look like a database connection at all.&lt;/p&gt;

&lt;p&gt;Who owns your functions? Whoever ran the migration that created them. Which is&lt;br&gt;
your migration role. Which is usually a superuser.&lt;/p&gt;

&lt;p&gt;Superusers are unconditionally exempt from row security, and &lt;code&gt;FORCE&lt;/code&gt; does not&lt;br&gt;
contain them — &lt;code&gt;FORCE&lt;/code&gt; only removes the &lt;em&gt;owner's&lt;/em&gt; exemption. So inside that&lt;br&gt;
function body, RLS is simply off.&lt;/p&gt;

&lt;p&gt;Consider a helper that looks completely harmless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;document_count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;
  &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt;
  &lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Called by your properly contained application role, against the seed data in&lt;br&gt;
the repo below — two documents belonging to Acme, one to Globex:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  as app_user · org context = Acme · documents has FORCE = true
  document_count() is owned by postgres (superuser = true)

      SELECT count(*) FROM documents   2  Acme's only, filtered
      SELECT document_count()          3  every tenant's
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same session. Same role. Same policies. The direct query is filtered and the&lt;br&gt;
function is not, because the function body executes as its owner.&lt;/p&gt;

&lt;p&gt;Read the two lines above the result, because together they are the whole&lt;br&gt;
condition. &lt;code&gt;documents&lt;/code&gt; has &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt; enabled — and it does&lt;br&gt;
not help. &lt;code&gt;FORCE&lt;/code&gt; removes the &lt;em&gt;owner's&lt;/em&gt; exemption; this function's owner is a&lt;br&gt;
superuser, and nothing removes that one.&lt;/p&gt;

&lt;p&gt;The corollary is worth having: if your tables are owned by a non-superuser&lt;br&gt;
role and you use &lt;code&gt;FORCE&lt;/code&gt;, a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function owned by that role&lt;br&gt;
stays contained. Whether this is a footnote or a breach comes down entirely to&lt;br&gt;
who owns the function.&lt;/p&gt;

&lt;p&gt;This is worse than it looks, for two reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not look like data access.&lt;/strong&gt; The obvious version of this bug is a&lt;br&gt;
function that returns rows. The realistic version is an audit trigger, a&lt;br&gt;
&lt;code&gt;updated_at&lt;/code&gt; maintenance function, a search helper, an RPC endpoint someone&lt;br&gt;
exposed through PostgREST. Nobody reviewing "add an audit trigger" is thinking&lt;br&gt;
about tenant isolation, and the function does not appear anywhere near your&lt;br&gt;
connection configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It survives every test in the first post.&lt;/strong&gt; Your role is contained. Your&lt;br&gt;
startup assertion passes. &lt;code&gt;SELECT&lt;/code&gt; is filtered. The isolation suite is green.&lt;br&gt;
The exemption is inside a function that the suite never calls.&lt;/p&gt;
&lt;h3&gt;
  
  
  Finding them
&lt;/h3&gt;

&lt;p&gt;Ask the catalog rather than grepping migrations, because migrations lie about&lt;br&gt;
what is actually in the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_get_userbyid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proowner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_proc&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pronamespace&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prosecdef&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pg_catalog'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'information_schema'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each row, one of three things has to be true: it does not touch&lt;br&gt;
tenant-scoped tables, it filters by organization itself, or it does not need&lt;br&gt;
to be &lt;code&gt;SECURITY DEFINER&lt;/code&gt; at all. &lt;code&gt;SECURITY INVOKER&lt;/code&gt; is the default and is&lt;br&gt;
almost always what you want.&lt;/p&gt;

&lt;p&gt;If a function genuinely needs elevated privileges, give it an owner that is&lt;br&gt;
&lt;em&gt;not&lt;/em&gt; a superuser — a role that owns only what the function needs, with&lt;br&gt;
&lt;code&gt;FORCE&lt;/code&gt; on the tables so ownership alone does not exempt it.&lt;/p&gt;

&lt;p&gt;And regardless: always pin the search path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;log_access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;
  &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
  &lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pg_temp&lt;/span&gt;   &lt;span class="c1"&gt;-- not optional&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it, a caller who can create objects in a schema earlier on the search&lt;br&gt;
path can shadow a table or operator the function references, and have your&lt;br&gt;
elevated function execute their definition. That is a separate escalation path&lt;br&gt;
that happens to live on the same feature.&lt;/p&gt;


&lt;h2&gt;
  
  
  Leak 2: constraints are an oracle, by design
&lt;/h2&gt;

&lt;p&gt;This one is not a mistake in your setup. It is documented behaviour, and it is&lt;br&gt;
load-bearing for the database's correctness. From the Postgres documentation&lt;br&gt;
on row security:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Referential integrity checks, such as unique or primary key constraints and&lt;br&gt;
foreign key references, always bypass row security to ensure that data&lt;br&gt;
integrity is maintained.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that again with a tenant boundary in mind. A unique index cannot enforce&lt;br&gt;
uniqueness if it can only see the rows you are allowed to read. So it sees all&lt;br&gt;
of them. So it reports collisions with rows you cannot read.&lt;/p&gt;

&lt;p&gt;Take a schema that looks fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;              &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;slug&lt;/span&gt;            &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;      &lt;span class="c1"&gt;-- the oracle&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Globex owns the slug &lt;code&gt;project-atlas&lt;/code&gt;. Acme goes looking for it, then tries to&lt;br&gt;
use it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;  &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt; &lt;span class="err"&gt;·&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Acme&lt;/span&gt; &lt;span class="err"&gt;·&lt;/span&gt; &lt;span class="n"&gt;Globex&lt;/span&gt; &lt;span class="n"&gt;owns&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="s1"&gt;'project-atlas'&lt;/span&gt;

      &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'project-atlas'&lt;/span&gt;   &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;  &lt;span class="n"&gt;correctly&lt;/span&gt; &lt;span class="n"&gt;invisible&lt;/span&gt;
      &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'project-atlas'&lt;/span&gt;
          &lt;span class="mi"&gt;23505&lt;/span&gt; &lt;span class="n"&gt;duplicate&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="n"&gt;violates&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="nv"&gt;"projects_slug_key"&lt;/span&gt;
          &lt;span class="k"&gt;constraint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;projects_slug_key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both halves are working as designed. The &lt;code&gt;SELECT&lt;/code&gt; returns zero rows — the&lt;br&gt;
policy is doing its job, and Acme genuinely cannot read that row. Then the&lt;br&gt;
unique index, which is not subject to the policy, reports the collision.&lt;/p&gt;

&lt;p&gt;Acme now knows with certainty that some other tenant has a project called&lt;br&gt;
&lt;code&gt;project-atlas&lt;/code&gt;. The read path is filtered perfectly. The row leaked through&lt;br&gt;
the &lt;strong&gt;error&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And it is enumerable. Walk a wordlist, and you map another tenant's project&lt;br&gt;
names one 23505 at a time. Do it against a &lt;code&gt;stripe_customer_id&lt;/code&gt; column and you&lt;br&gt;
confirm whether a specific company is a customer. Do it against an invite&lt;br&gt;
email and you learn who works where.&lt;/p&gt;

&lt;p&gt;Foreign keys leak in the other direction: a reference to a row you cannot read&lt;br&gt;
resolves successfully, so a &lt;em&gt;successful&lt;/em&gt; insert tells you a row exists. Same&lt;br&gt;
oracle, inverted.&lt;/p&gt;
&lt;h3&gt;
  
  
  Closing it where you can
&lt;/h3&gt;

&lt;p&gt;Where the value only has to be unique &lt;em&gt;per tenant&lt;/em&gt; — and this is most values —&lt;br&gt;
put the tenant in the constraint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;-- not UNIQUE (slug)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swapping the constraint on the same table, with the same data and the same&lt;br&gt;
policy, and repeating the exact insert that just failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;  &lt;span class="k"&gt;after&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;replaces&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
      &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="n"&gt;succeeded&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="k"&gt;no&lt;/span&gt; &lt;span class="n"&gt;information&lt;/span&gt; &lt;span class="n"&gt;crossed&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;boundary&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The collision now happens only against rows in the caller's own tenant, which&lt;br&gt;
they are allowed to know about. Globex still has its &lt;code&gt;project-atlas&lt;/code&gt; and Acme&lt;br&gt;
still cannot see it — but Acme can now have one too, and learns nothing by&lt;br&gt;
asking. The channel closes completely, and it costs one column in an index.&lt;/p&gt;

&lt;p&gt;This is the entire fix, and it has to be a habit rather than an audit, because&lt;br&gt;
retrofitting it means backfilling values that are currently colliding across&lt;br&gt;
tenants.&lt;/p&gt;
&lt;h3&gt;
  
  
  Where you cannot
&lt;/h3&gt;

&lt;p&gt;Some values genuinely have to be globally unique: a subdomain, a public&lt;br&gt;
username, an external system's customer id. Schema design cannot help you&lt;br&gt;
there, and I want to be straight about that rather than pretend otherwise.&lt;/p&gt;

&lt;p&gt;What you can do is narrow the channel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never return the constraint error verbatim.&lt;/strong&gt; Catch &lt;code&gt;23505&lt;/code&gt; and reply with
something that does not distinguish "taken by you" from "taken by someone
else" — for a subdomain, "that subdomain is not available" is honest and
says less than the raw error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate-limit the endpoint that produces the check.&lt;/strong&gt; An oracle you can query
three times a minute is a very different threat from one you can query
three thousand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide whether it is actually a secret.&lt;/strong&gt; Subdomains are usually public by
design; a Stripe customer id is not. The mitigation should follow the
sensitivity, not the mechanism.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that closes it. It is a real, accepted limit of doing multi-tenancy in&lt;br&gt;
a shared schema, and it is the strongest argument I know for schema-per-tenant&lt;br&gt;
when your uniqueness requirements are genuinely global.&lt;/p&gt;


&lt;h2&gt;
  
  
  What this changes about testing
&lt;/h2&gt;

&lt;p&gt;The pattern shared by both leaks is that &lt;strong&gt;the read path is fine.&lt;/strong&gt; A test&lt;br&gt;
suite that asserts "tenant A cannot select tenant B's rows" passes in both&lt;br&gt;
cases, because in both cases tenant A genuinely cannot select them.&lt;/p&gt;

&lt;p&gt;So the assertions have to target the side channels directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;call every &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function as the contained app role, with a
tenant context set, and assert it returns only that tenant's data&lt;/li&gt;
&lt;li&gt;insert a colliding value across a tenant boundary and assert you get a clean
application-level failure rather than a raw constraint violation&lt;/li&gt;
&lt;li&gt;assert the catalog itself: that no &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function outside an
allowlist is owned by a superuser&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the same move as refusing to boot on an exempt role — turning&lt;br&gt;
a thing you got right once into a thing that cannot silently stop being right.&lt;/p&gt;


&lt;h2&gt;
  
  
  Runnable
&lt;/h2&gt;

&lt;p&gt;Both of these are a command in the MIT repo, so you do not have to take any of&lt;br&gt;
the output above on trust:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/wenceslauAndre/postgres-rls-multi-tenancy" rel="noopener noreferrer"&gt;https://github.com/wenceslauAndre/postgres-rls-multi-tenancy&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
npm run setup
npm run leak             &lt;span class="c"&gt;# the role exemption from the first post&lt;/span&gt;
npm run side-channels    &lt;span class="c"&gt;# every block of output in this post&lt;/span&gt;
npm &lt;span class="nb"&gt;test&lt;/span&gt;                 &lt;span class="c"&gt;# nine assertions against a live server&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;npm run side-channels&lt;/code&gt; is where the two outputs above come from. It builds&lt;br&gt;
the &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function and the globally-unique constraint, shows them&lt;br&gt;
leaking, swaps the constraint to prove the fix closes it, and drops everything&lt;br&gt;
it made — the schema in &lt;code&gt;sql/&lt;/code&gt; models the correct pattern, so the&lt;br&gt;
anti-patterns are built at runtime rather than shipped in the reference&lt;br&gt;
schema for someone to copy.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;memberships&lt;/code&gt; table there carries &lt;code&gt;UNIQUE (organization_id, user_id)&lt;/code&gt; with&lt;br&gt;
a comment explaining why the &lt;code&gt;organization_id&lt;/code&gt; is in the constraint. That is&lt;br&gt;
the entire lesson from leak 2, sitting where someone copying the schema will&lt;br&gt;
actually read it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The open question
&lt;/h2&gt;

&lt;p&gt;I do not have a good answer for the globally-unique case, and I have not found&lt;br&gt;
one written down. Deferring the constraint does not help — the check still&lt;br&gt;
runs, just later. A mediating table with its own policy moves the oracle&lt;br&gt;
rather than removing it, since the mediating table needs the global unique.&lt;/p&gt;

&lt;p&gt;If you have solved this without going schema-per-tenant, I would like to hear&lt;br&gt;
how. It is the one part of this pattern where I know the answer is "you&lt;br&gt;
cannot", and I would be glad to be wrong.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Both traps in this post came from Rahul S, in the comments on the previous&lt;br&gt;
one. Neither was in the first version of my repo. Both survive a setup that is&lt;br&gt;
otherwise completely correct, which is exactly the kind of gap you do not find&lt;br&gt;
by staring at your own work.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I also sell &lt;a href="https://tenantforge.dev" rel="noopener noreferrer"&gt;TenantForge&lt;/a&gt;, a multi-tenant&lt;br&gt;
SaaS starter built on this pattern. The repo above is MIT and standalone —&lt;br&gt;
nothing in it is a teaser.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Postgres RLS multi-tenancy: two traps that silently disable your policies</title>
      <dc:creator>André Wenceslau</dc:creator>
      <pubDate>Sat, 25 Jul 2026 21:02:34 +0000</pubDate>
      <link>https://dev.to/wenceslaudev/postgres-rls-multi-tenancy-two-traps-that-silently-disable-your-policies-5gn8</link>
      <guid>https://dev.to/wenceslaudev/postgres-rls-multi-tenancy-two-traps-that-silently-disable-your-policies-5gn8</guid>
      <description>&lt;p&gt;Most multi-tenant applications keep tenants apart with one line of code,&lt;br&gt;
repeated forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line is load-bearing. Forget it once — in a new endpoint, in a join, in&lt;br&gt;
a hotfix at 2am, in a report someone added last quarter — and one customer&lt;br&gt;
reads another's data. Nothing crashes. No test fails. You find out from a&lt;br&gt;
support ticket, if you find out at all.&lt;/p&gt;

&lt;p&gt;Postgres Row Level Security moves that rule into the database, where&lt;br&gt;
forgetting it is not an option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_org_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application sets one session variable per transaction, right after it has&lt;br&gt;
decided which organization the request belongs to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT set_config('app.current_org_id', $1, true)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// every query in this transaction is now scoped, whether it says so or not&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A forgotten &lt;code&gt;WHERE&lt;/code&gt; now returns zero rows instead of somebody else's data.&lt;br&gt;
The bug becomes a blank page rather than a breach.&lt;/p&gt;

&lt;p&gt;That much is in every RLS tutorial. Below are the two things that decide&lt;br&gt;
whether any of it actually works, both of which I got wrong.&lt;/p&gt;


&lt;h2&gt;
  
  
  Trap 1: your policies do not apply to the role you are probably using
&lt;/h2&gt;

&lt;p&gt;Three kinds of role ignore row level security entirely:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Exempt?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;superuser&lt;/td&gt;
&lt;td&gt;always — &lt;code&gt;FORCE&lt;/code&gt; does not contain it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;role with &lt;code&gt;BYPASSRLS&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;always&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the table's owner&lt;/td&gt;
&lt;td&gt;under &lt;code&gt;ENABLE&lt;/code&gt;; contained by &lt;code&gt;FORCE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now look at your connection string. In most tutorials, most ORM guides and&lt;br&gt;
most &lt;code&gt;docker-compose.yml&lt;/code&gt; files, the application connects with the same role&lt;br&gt;
that created the tables and runs the migrations. That role is usually a&lt;br&gt;
superuser, or at minimum the table owner.&lt;/p&gt;

&lt;p&gt;Which means the policies you just wrote do nothing at all. They are in the&lt;br&gt;
schema. They pass code review. They filter nothing.&lt;/p&gt;

&lt;p&gt;Here is the same query, with the same policies, over two connections:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npm run leak
&lt;span class="go"&gt;
  as postgres (superuser — always exempt from RLS)
      Acme roadmap
      Acme salaries
      Globex acquisition memo
      3 rows — other tenants included

  as app_user (NOBYPASSRLS, owns nothing)
      Acme roadmap
      Acme salaries
      2 rows — Acme's only
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing about the policies changed between those two queries. Exemption is a&lt;br&gt;
property of the &lt;em&gt;role&lt;/em&gt;, not of the table.&lt;/p&gt;

&lt;p&gt;The fix is a second role. Migrations keep running as the owner; the&lt;br&gt;
application connects as one that owns nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt; &lt;span class="n"&gt;NOBYPASSRLS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt; to the tables too. It removes the owner's&lt;br&gt;
exemption, which is a useful seatbelt for the day someone points the app at&lt;br&gt;
the wrong connection string. It will not save you from a superuser — nothing&lt;br&gt;
will.&lt;/p&gt;

&lt;p&gt;This split is cheap to do on day one and miserable to retrofit once you have&lt;br&gt;
production data and a dozen services connecting.&lt;/p&gt;


&lt;h2&gt;
  
  
  Trap 2: &lt;code&gt;set_config&lt;/code&gt; leaves an empty string behind, and pools remember
&lt;/h2&gt;

&lt;p&gt;This one cost me an afternoon.&lt;/p&gt;

&lt;p&gt;The symptom was an intermittent 500:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;invalid input syntax for type uuid: ""
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLSTATE &lt;code&gt;22P02&lt;/code&gt;. It only happened on requests that ran &lt;strong&gt;without&lt;/strong&gt; a tenant&lt;br&gt;
context — an organization switcher, a post-login redirect — and only after&lt;br&gt;
some other request had already used that same physical connection. On a fresh&lt;br&gt;
pool it never reproduced. Restarting the app "fixed" it for a while.&lt;/p&gt;

&lt;p&gt;The cause is a detail of &lt;code&gt;set_config(name, value, is_local)&lt;/code&gt;. Passing &lt;code&gt;true&lt;/code&gt;&lt;br&gt;
scopes the setting to the current transaction, which is exactly what you want&lt;br&gt;
on a pooled connection. But when that transaction ends, the custom GUC does&lt;br&gt;
not become unset or &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It becomes the &lt;strong&gt;empty string&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the next request to reuse that connection without setting an org context&lt;br&gt;
evaluates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and raises &lt;code&gt;22P02&lt;/code&gt;. Note that the &lt;code&gt;missing_ok = true&lt;/code&gt; second argument to&lt;br&gt;
&lt;code&gt;current_setting&lt;/code&gt; does not help here — the setting is not missing. It is&lt;br&gt;
present, and it is empty.&lt;/p&gt;

&lt;p&gt;The fix is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_org_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NULLIF&lt;/code&gt; turns &lt;code&gt;''&lt;/code&gt; back into &lt;code&gt;NULL&lt;/code&gt;. &lt;code&gt;NULL&lt;/code&gt; casts cleanly and matches no&lt;br&gt;
rows, so the failure mode becomes "you see nothing" instead of a crash — and,&lt;br&gt;
much more importantly, instead of any fallback that might show everything.&lt;br&gt;
For an isolation policy, that is the direction you want it to fail in.&lt;/p&gt;

&lt;p&gt;Two things worth noting about this bug:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is invisible to unit tests.&lt;/strong&gt; It needs a real connection pool against a&lt;br&gt;
real server, and it needs a &lt;em&gt;prior&lt;/em&gt; request on the same connection to have&lt;br&gt;
set a context. Any test that mocks the query layer passes happily.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It gets worse under load, not better.&lt;/strong&gt; More concurrency means more&lt;br&gt;
connection reuse means more chances to hit a recycled connection. It looks&lt;br&gt;
like a flaky bug in staging and a real one in production.&lt;/p&gt;


&lt;h2&gt;
  
  
  What RLS does not do
&lt;/h2&gt;

&lt;p&gt;Worth being explicit, because it is easy to oversell:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It does not authorize.&lt;/strong&gt; RLS filters rows once you have set an org
context. Deciding whether this user may enter that organization at all is
application code, and it has to run &lt;em&gt;before&lt;/em&gt; the context is set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is a second line of defense.&lt;/strong&gt; Keep writing the &lt;code&gt;WHERE&lt;/code&gt; clause. The
point is that forgetting it stops being catastrophic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs something.&lt;/strong&gt; Policies are predicates on every query. Index your
&lt;code&gt;organization_id&lt;/code&gt; columns and read your plans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bootstrap queries need an exception.&lt;/strong&gt; Looking up a session, or an invite
by its token, happens before you know the tenant. Those go through the
owner connection deliberately — a short, named, auditable list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WITH CHECK&lt;/code&gt; is not optional.&lt;/strong&gt; A &lt;code&gt;USING&lt;/code&gt; clause alone controls reads.
Without &lt;code&gt;WITH CHECK&lt;/code&gt;, a tenant can &lt;em&gt;write&lt;/em&gt; rows into another tenant it
cannot read.&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  A runnable version
&lt;/h2&gt;

&lt;p&gt;I extracted the pattern into a small MIT repo while debugging all of the&lt;br&gt;
above — four tables, three SQL files, no framework:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/wenceslauAndre/postgres-rls-multi-tenancy" rel="noopener noreferrer"&gt;https://github.com/wenceslauAndre/postgres-rls-multi-tenancy&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
npm run setup
npm run leak    &lt;span class="c"&gt;# the two-role output above&lt;/span&gt;
npm &lt;span class="nb"&gt;test&lt;/span&gt;        &lt;span class="c"&gt;# seven assertions against a live server&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test suite asserts both traps, plus isolation, &lt;code&gt;WITH CHECK&lt;/code&gt; containment,&lt;br&gt;
and the pre-context membership read that an org switcher needs. All of them&lt;br&gt;
are properties of the database rather than of application code, which is why&lt;br&gt;
they run against a real server instead of a mock.&lt;/p&gt;

&lt;p&gt;If you know whether the revert-to-empty-string behaviour for custom GUCs is&lt;br&gt;
documented explicitly somewhere, I would genuinely like to know — I found it&lt;br&gt;
by bisecting, not by reading.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I also sell &lt;a href="https://tenantforge.dev" rel="noopener noreferrer"&gt;TenantForge&lt;/a&gt;, a multi-tenant&lt;br&gt;
SaaS starter built on this pattern. The repo above is MIT and standalone —&lt;br&gt;
nothing in it is a teaser.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
