Prisma and Drizzle connect as the postgres role and bypass Supabase RLS entirely, so your policies never protect ORM queries. Here's the fix.
TL;DR: No. Prisma and Drizzle open their own direct Postgres connection and log in as the postgres role, which owns your tables and carries BYPASSRLS — so Row Level Security is skipped on every ORM query. Point your app's connection at a dedicated, non-owner NOBYPASSRLS role (and keep the auth check in your code), not at postgres.
If you built your Supabase project assuming RLS is a safety net on the data itself, adding an ORM quietly punches a hole straight through it. Your policies are still there. They just never run for the ORM's connection. Here's the mechanism, the myth to unlearn, and three fixes in order of how much you should reach for them.
Does Prisma respect Supabase RLS?
No. RLS is not a global property of the database — Postgres enforces it per role, per statement. A policy only bites for a role that is (a) not the table's owner, (b) has no BYPASSRLS attribute, and (c) is not a superuser. The Supabase JS client satisfies all three because it reaches Postgres through PostgREST, which runs your query as the unprivileged anon or authenticated role. Prisma and Drizzle satisfy none of them: they read DATABASE_URL and open a raw SQL connection as postgres, which owns virtually every table you migrated and holds BYPASSRLS. Either fact alone is enough for Postgres to skip your policies.
So the same query that returns one tenant's rows through supabase-js returns every tenant's rows through Prisma. That is not a bug in your policy — it's the connection role.
Two doors into the same database
There are two completely different paths to your data, and they authenticate as different roles.
The supabase-js path (RLS enforced). supabase-js talks HTTP to PostgREST, not to Postgres directly. PostgREST connects as authenticator, validates the request JWT, and does a SET ROLE into anon or authenticated for the statement. Those are deliberately unprivileged, non-owning roles, so policies get evaluated.
The ORM path (RLS not enforced). Prisma and Drizzle are ordinary Postgres clients. They bypass PostgREST entirely and log in as postgres, whether you connect direct on port 5432, through the session pooler on 5432, or through the transaction pooler on 6543. The port and the pooler are irrelevant — the pooler is just a proxy in front of Postgres. What determines RLS enforcement is the login role, and by default that's postgres for all three connection styles.
If you want the ground-up model of how policies and roles interact, the complete guide to Supabase Row Level Security walks through it from scratch.
Why the postgres role skips RLS (it's not superuser)
The common explanation — "because postgres is a superuser" — is false on the Supabase managed platform, and it's worth correcting because it points people at the wrong fix. Supabase withholds superuser from postgres; the only true superuser is the internal supabase_admin role. Operations that require superuser, like COPY … FROM PROGRAM, are blocked for postgres.
The bypass comes from two other Postgres rules, either of which is independently sufficient:
Table ownership. When Supabase runs your migrations, the resulting tables in public are owned by the postgres role. Per the Postgres manual, a table's owner bypasses that table's RLS by default. Since the ORM connects as postgres, it's the owner of nearly every app table.
The BYPASSRLS attribute. Supabase's role config also gives postgres the BYPASSRLS attribute. From the PostgreSQL manual's Row Security Policies section:
"Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table."
Note the word "always" for BYPASSRLS — no table-level setting overrides it. Table owners bypass too, though the manual notes an owner can opt back in with ALTER TABLE ... FORCE ROW LEVEL SECURITY. This is the same reason the service_role key bypasses RLS: Supabase creates it as create role service_role nologin noinherit bypassrls;.
The consequence developers get wrong
RLS policies protect the PostgREST path only — the anon/authenticated roles that supabase-js, the Data API, and the auto-generated REST endpoints run as. They do nothing for a direct SQL connection as postgres.
The dangerous mental model is "RLS is a global safety net on the data." It isn't. The moment you add Prisma or Drizzle with the default DATABASE_URL, you've opened a second door that walks past every policy: every SELECT/INSERT/UPDATE/DELETE the ORM issues sees and mutates all rows for all tenants. Teams routinely ship an ORM-backed API next to a "secured by RLS" project and assume tenant isolation still holds. On the ORM path, it does not.
Fix 1: Use supabase-js for user-reachable data, ORM for trusted server work
The simplest and most honest architecture is a hybrid, and it's what most production Supabase apps actually run:
-
supabase-jsfor auth, realtime, storage, and user-facing CRUD → RLS enforced for free. - Prisma/Drizzle for complex relational queries, transactions, and admin/migration work → treated as trusted server-side access where you enforce authorization in your application code.
Be clear with yourself about what fix 1 is: it is not RLS. It's "we checked auth in the handler." An ORM connected as postgres with RLS merely ENABLEd on the table gives you zero database-level protection, because the owner bypasses it. Which means the app-layer check is now the only thing standing between a request and a write — more on that below.
Fix 2: Enforce RLS through the ORM with set_config + SET LOCAL ROLE
If you genuinely want your existing policies to run on the ORM path, you have to reproduce what PostgREST does: per request, inside one transaction, set the role to a non-owner role and set the JWT claims, run the query, then reset.
import { Prisma, PrismaClient } from "@prisma/client";
async function withUserRls<T>(
prisma: PrismaClient,
jwt: { sub: string; role: string },
fn: (tx: Prisma.TransactionClient) => Promise<T>,
) {
return prisma.$transaction(async (tx) => {
const role = jwt.role === "authenticated" ? "authenticated" : "anon";
await tx.$executeRawUnsafe(`SET LOCAL ROLE ${role}`);
await tx.$executeRaw`SELECT set_config('request.jwt.claims', ${JSON.stringify(jwt)}, true)`;
return fn(tx);
});
}
Three non-negotiables: the connection role must not be the table owner and must not have BYPASSRLS (see fix 3); everything must be transaction-scoped (a session-level SET persists on a pooled connection and leaks into the next user's request); and you must set the role and the claims — the popular Prisma RLS extension sets the claims but not the role, so TO authenticated policies silently won't match.
Fix 3: A dedicated least-privileged role (and why FORCE alone isn't enough)
The robust fix underneath fixes 1 and 2 is the same: stop connecting your app as postgres. Create a login role that owns nothing and has no BYPASSRLS, grant it the Supabase request roles, then point the runtime DATABASE_URL at it:
create role app_user with login password 'REPLACE_WITH_STRONG_SECRET'
noinherit nobypassrls;
grant anon, authenticated to app_user;
grant usage on schema public to app_user;
grant select, insert, update, delete on all tables in schema public to app_user;
grant usage, select on all sequences in schema public to app_user;
alter default privileges in schema public
grant select, insert, update, delete on tables to app_user;
Because app_user is neither owner nor BYPASSRLS, plain ENABLE ROW LEVEL SECURITY is now genuinely enforced for it. Keep a separate postgres string for migrations only.
ALTER TABLE … FORCE ROW LEVEL SECURITY subjects the table owner to policies — but does nothing to BYPASSRLS roles. So if your ORM still connects as postgres, FORCE alone will not save you. The primary control is the connection role.
Where this actually bites: the app-layer auth check
Once you accept fix 1 — the ORM is trusted, RLS is not your backstop on that path — the application-layer auth check becomes the only thing protecting a write. Here's the kind of Server Action that quietly becomes a hole:
"use server";
import { prisma } from "@/lib/prisma";
export async function deleteDocument(id: string) {
// No auth check. RLS won't save you here — Prisma
// connects as `postgres` and bypasses every policy.
return prisma.document.delete({ where: { id } });
}
When the database has stopped being your safety net, a missing guard in the handler is a real, exploitable hole. Verify the user at the top of every action that mutates data — see the auth check every Server Action needs. And keep the direct connection string server-only and out of git (keeping secrets out of your Next.js bundle).
Write these while building GuardLayer, a static scanner for Next.js + Supabase apps. It can't see your DB connection role — but it does flag a Server Action doing a Prisma/Drizzle write with no auth check, exactly the gap this bypass creates. Originally published on the GuardLayer blog.
Top comments (1)
The connection-role explanation is the key lesson. I would make the fix verifiable rather than configuration-dependent: run tenant-isolation tests in CI using the exact runtime connection string and pooler mode, then assert
current_user,session_user,rolbypassrls, table ownership, active claims, and both cross-tenant read and write denial. Also test connection reuse after the transaction to proveSET LOCAL ROLEand claims do not leak to the next request. Keeping migrations on a separate owner credential is essential, but the strongest regression test is a planted tenant-B row that the tenant-A runtime path can neither select nor mutate.