<?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%2Fa2175356-2156-48a4-9915-24ed68301466.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 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>
