← All posts
·10 min read·GuardLayer

Does Prisma respect Supabase RLS? No — here's why

SupabaseRLSPostgresPrismaDrizzle

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, our 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-js for 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. Supabase's auth.uid() and auth.jwt() read current_setting('request.jwt.claims'), and TO authenticated policies also check the current Postgres role — so you have to set both.

Prisma has no first-class RLS wrapper, so wrap each query in a transaction. A hand-rolled version keeps the behavior explicit:

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) => {
    // A role is an identifier, not a value — it cannot be a bind
    // parameter. Whitelist it; never interpolate raw input.
    const role = jwt.role === "authenticated" ? "authenticated" : "anon";
    await tx.$executeRawUnsafe(`SET LOCAL ROLE ${role}`);
    // Claims go in as a bind parameter — auth.uid()/auth.jwt() read this.
    await tx.$executeRaw`SELECT set_config('request.jwt.claims', ${JSON.stringify(jwt)}, true)`;
    return fn(tx);
  });
}

Three non-negotiables here:

  1. The connection role must not be the table owner and must not have BYPASSRLS, or all of this is theater (see fix 3).
  2. Everything must be transaction-scopedSET LOCAL and set_config(..., true) are cleaned up at commit. A session-level SET would persist on a pooled connection and leak into the next user's request — a real cross-tenant vulnerability behind the transaction pooler.
  3. Set the role and the claims. The popular Prisma RLS extension sets request.jwt.claims but not the role — so TO authenticated policies silently won't match unless you also SET LOCAL ROLE. Many readers ship broken policies this way.

Drizzle users have the community rphlmr/drizzle-supabase-rls reference wrapper, which does the same set_config + set local role dance in a transaction. One caveat: it interpolates the token via sql.raw rather than a bind parameter — tolerable only because the token is a cryptographically signed JWT you decoded server-side. Prefer the parameterized set_config('request.jwt.claims', ${JSON.stringify(token)}, true) form, and always whitelist the role string.

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 so SET LOCAL ROLE works, then point the runtime DATABASE_URL at it:

-- Login role that owns nothing and cannot bypass RLS
create role app_user with login password 'REPLACE_WITH_STRONG_SECRET'
  noinherit nobypassrls;

-- Let it assume Supabase's request roles (for SET LOCAL ROLE ...)
grant anon, authenticated to app_user;

-- Minimum object privileges — RLS still filters rows on top of these
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.

What about ALTER TABLE … FORCE ROW LEVEL SECURITY? It subjects the table owner to policies, closing the ownership hole — but it does nothing to BYPASSRLS roles or superusers, which always bypass. So if your ORM is still connecting as postgres (which has BYPASSRLS), FORCE alone will not save you. Treat FORCE as a belt-and-suspenders backstop for tables you occasionally query as the owner, not as the primary control. The primary control is the connection role.

alter table public.documents enable row level security;
alter table public.documents force  row level security;  -- backstop, not a fix

Where GuardLayer actually helps (and where it can't)

Straight talk, because this is the whole point: GuardLayer is a static file scanner. It cannot see your database role or connection string at runtimeDATABASE_URL is an env var, not something committed to your repo. So GuardLayer does not and will not claim to detect "your ORM bypasses RLS." It can't see the role your ORM logs in as. Anyone claiming a static scanner catches that is selling you something.

And its supabase/... RLS rules? Those are about the PostgREST path — a table with RLS disabled, a policy that resolves auth.uid() to null, and so on. Useful, but they do not help your ORM queries, because those queries never touch that path. Be honest with yourself about that gap.

Here's where GuardLayer is genuinely load-bearing for this problem. 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. That's exactly the gap GuardLayer's nextjs/server-action-no-auth rule (CWE-862, warning) flags: a Server Action ("use server") that performs a write — prisma.<model>.create/update/delete, db.insert/update/delete, or a Drizzle mutation — with no detectable auth or authorization check. When the database has stopped being your safety net, a missing guard in the handler is a real, exploitable hole, and that's the one a static scanner can see in your source. Pair it with our writeup on the auth check every Server Action needs.

guardlayer scan · app/actions/documents.tsLive engine output
Passed with warnings
92/100 · A
  • Warningapp/actions/documents.ts:8

    Server Action without auth check

    At the top of every Server Action, resolve and verify the current user (e.g. const { user } = await getUser(); if (!user) throw …) and authorize the specific operation before mutating data. If you do guard it with a custom helper, this warning is a false positive.

Secondary catch: if someone hardcodes the direct Postgres connection string into a committed file, GuardLayer's general/hardcoded-secret rule flags it (CONNECTION_STRING is a strong-secret marker). Keep the direct connection server-only and out of git history — the same discipline covered in keeping secrets out of your Next.js bundle.

FAQ

Does Prisma respect Supabase RLS? No. Prisma connects directly to Postgres as the postgres role, which owns your tables and has BYPASSRLS, so RLS policies are skipped on every Prisma query. You must either connect as a dedicated non-owner role or enforce authorization in your application code.

Does Drizzle bypass Supabase RLS too? Yes — for the same reason. Drizzle is a plain Postgres client using DATABASE_URL. If that string authenticates as postgres, RLS is bypassed. Use the rphlmr/drizzle-supabase-rls transaction wrapper plus a least-privileged role to enforce it.

Is it the connection pooler (port 6543) that bypasses RLS? No. The port and pooler are irrelevant. The login role determines RLS enforcement. Whether you go direct on 5432 or through the transaction pooler on 6543, if you log in as postgres, RLS is skipped.

Does ALTER TABLE ... FORCE ROW LEVEL SECURITY fix ORM queries? Only partially. FORCE subjects the table owner to policies, but it has no effect on BYPASSRLS roles or superusers. Since Supabase's postgres role has BYPASSRLS, FORCE alone won't stop it. Change the connection role instead.

Is Supabase's postgres role a superuser? No. On the managed platform, superuser is withheld from postgres; the only true superuser is supabase_admin. The RLS bypass comes from table ownership plus BYPASSRLS, not superuser status.

How do I enforce RLS with Prisma? Connect as a dedicated role that is not the table owner and has NOBYPASSRLS, then wrap each query in a transaction that runs SET LOCAL ROLE authenticated and set_config('request.jwt.claims', ...) before the query so your existing policies evaluate against the request identity.

Catch this before it ships — free

GuardLayer scans every push for this and 23 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading