My co-founder James just shipped the first of two big swings at Row Level Security in PgCache, our caching proxy for Postgres. In case it's not obvious, I'm one of the founders, but the two tricks he used are pretty neat on their own, so I wanted to share them here.
The problem with caching RLS tables is pretty simple: If your cache keys on the SQL text, you give tenant 7's orders to tenant 8, so until now we told anyone using RLS to leave those tables out of PgCache.
1- The policy is just part of the WHERE clause
Back in August, James told me "RLS itself is just an extension to the WHERE clause," and that's basically how it works now. PgCache asks Postgres which policies apply to the session's role, fills in the session's own values, and folds them into the query:
CREATE POLICY tenant_policy ON orders
USING (tenant_id = current_setting('app.tenant_id')::int);
-- after SET app.tenant_id = '7', this query
SELECT * FROM orders WHERE status = 'open';
-- gets cached as
SELECT * FROM orders WHERE status = 'open' AND tenant_id = 7;
So every tenant gets its own cache entry (and two sessions for the same tenant share one).
The obvious alternative was a cache entry per role, but then PgCache couldn't check new rows from the replication stream against the policy, so every insert into an RLS table would mean throwing cached results away.
Once the policy is a plain filter, the replication stream keeps each tenant's results fresh in place, like any other query. The tradeoff is that the role PgCache connects with needs to see every row for when it fills the cache (but if you're using PgCache you're already using a role that has access to logical replication anyway).
2- Getting DDL into the replication stream
The big challenge is that there's not enough in the logical replication stream to actually support Row Level Security. Logical replication is all about rows, so it never tells you when someone runs CREATE POLICY or REVOKE.
Our fix is a pair of event triggers on your database that turn each DDL command into a logical message:
PERFORM pg_logical_emit_message(true, 'pgcache', <the objects that changed>);
That true makes it transactional, so the message lands in the stream (and in the right order) inside the same transaction. And there's no extension to install.
Roles were the one gap, since event triggers don't fire for ALTER ROLE or GRANT role TO role. James really didn't want to poll anything, but PgCache now reads pg_roles and pg_auth_members every 5 seconds, which turned out to be way cheaper than the alternatives.
The second of our 2 big swings will be policies built on JWT claims (Supabase's auth.uid()), which still pass through to Postgres for now. Plan is to add other rare policy shapes on an as-needed basis, but Supabase feels like tablestakes for saying "our proxy is feature complete" in 2026.
James wrote up all the details in the repo: ADR-062 for the policy inlining, ADR-058 for the event triggers and ADR-061 for privileges and the role poll. The compatibility docs list what's cached and what passes through.
Curious to know how you guys are actually passing the tenant through to Postgres in production (custom setting, SET ROLE per tenant, JWT claims)?