Supabase vs Prisma: An ORM Cannot See Your Row-Level Security
Point Prisma at a Supabase database and your policies stop working — in one of two directions, neither the one you wrote. Our own six policies, audited against that.
Every comparison of these two opens by saying they are different categories — Supabase is a hosted Postgres platform, Prisma is a TypeScript ORM — and then stops, usually at "you can use them together." True, and it buries the consequence. Put Prisma in front of a Supabase database and your row-level security policies stop working. Not degrade: stop, in one of two directions, neither of them the one you wrote.
The question that actually decides it
Both talk to Postgres. The difference that matters is how the database learns who is asking.
| Supabase client | Prisma Client | |
|---|---|---|
| Connects as | Postgres role authenticated, carrying the end user's JWT | One database role from a connection string, for every user |
| Caller identity available in SQL | auth.uid(), per request | None — there is no per-request identity |
| Where a permission is expressed | A policy on the table | A where clause in application code |
| Who can forget to apply it | Nobody; the policy runs on every query | Any developer writing any query |
| Type safety of results | None by default | Generated, checked at compile time |
That last row is Prisma's genuine advantage and this post gets to it. But start with the identity row, because it is the one that silently changes the security posture of an application.
What auth.uid() needs, and why a connection string cannot supply it
Here are four of this storefront's six policies, verbatim:
-- supabase/migrations/0001_commerce_init.sql
create policy "own profile" on public.profiles for select using ((select auth.uid()) = id);
create policy "own orders" on public.orders for select using ((select auth.uid()) = user_id);
create policy "own slots" on public.entitlement_slots for select using ((select auth.uid()) = user_id);
create policy "own downloads" on public.download_events for select using ((select auth.uid()) = user_id);
auth.uid() is not a Postgres built-in. It reads a request-scoped setting that Supabase populates from the verified JWT the client sent. Nothing else populates it.
Prisma opens a pooled connection with a username and a password. There is no JWT anywhere in that path, so the setting is never populated and auth.uid() returns NULL. From there, exactly which of two failure modes you get depends on a detail buried in your connection string:
- Connection string names the table owner (the common case —
postgres). Postgres exempts table owners from row security unless the table was declaredFORCE ROW LEVEL SECURITY. Ours areENABLE, notFORCE, like almost every schema written for Supabase. Every policy is bypassed. Every query sees every row. - Connection string names a non-owner role without
BYPASSRLS. Policies apply,auth.uid()isNULL,NULL = user_idisNULL, andNULLis nottrue. Every query sees nothing.
So the two outcomes are "all rows" and "no rows". The policy you actually wrote — this user's rows — is not reachable, because the input it depends on does not exist on that connection. The first outcome is the dangerous one, and it is the default, and it produces no error.
Auditing our own six policies against that
This is the exercise worth running before adopting an ORM on an RLS schema. For each policy: what enforces it afterwards?
| Policy | Enforced today by | Under Prisma, enforced by |
|---|---|---|
own profile | Postgres, every query | A where: { id: session.userId } a developer must remember |
own orders | Postgres, every query | Same, in every query touching orders |
own slots | Postgres, every query | Same, including joins that reach slots indirectly |
own downloads | Postgres, every query | Same |
own deliveries | Postgres, every query | Same |
own refund requests | Postgres, every query | Same |
| (no write policy exists for any signed-in role) | Writes are impossible from a client session | Writes are ordinary Prisma calls |
That bottom row is the sharpest one. There are zero write policies here for anon or authenticated. Writes happen only through SECURITY DEFINER functions with a pinned search_path and EXECUTE revoked from the client roles. A signed-in session physically cannot insert or update a row in these tables. Replace the transport with an ORM connecting as the owner and that guarantee is not weakened — it is gone, replaced by the discipline of whoever writes the next query.
This codebase already has exactly one path that bypasses RLS on purpose, and how it is fenced is the point:
// src/lib/supabase/admin.ts
import "server-only";
/** Service-role Supabase client — SERVER ONLY. Bypasses RLS. */
That server-only import makes an accidental import from a Client Component a build failure. The bypass is one file, named as a bypass, guarded by the compiler. Under an ORM every query runs on that footing and there is nothing left to guard.
Where Prisma is straightforwardly better, measured against our code
Our Supabase clients are constructed with no schema generic, so query results are not typed against the database. What the codebase does instead is hand-write the row shapes:
// src/app/dashboard/page.tsx
interface OrderRow {
id: string;
tier: "t1" | "t2" | "t3";
status: "paid" | "refunded";
total_cents: number | null;
currency: string | null;
created_at: string;
raw: unknown;
}
Nothing checks that interface against the actual orders table. Rename a column in a migration and this compiles perfectly while returning undefined at runtime. That is precisely the class of bug Prisma's generated client eliminates, and it is a real cost we are carrying — the honest version of this comparison has to say so rather than pretending the platform wins on every axis.
Prisma Migrate is the second real advantage: schema as a declarative file with generated migrations, versus the eight hand-written SQL files in supabase/migrations/. Hand-written SQL is more expressive — nothing in a Prisma schema expresses the advisory lock below — but it is also eight files a human has to keep ordered and correct.
The concurrency case, which is not a win for either side
The download limiter here shipped with a real defect: a check-then-act race, CWE-367, where the hourly cap was read and the audit row inserted in two separate calls. Under concurrency, N parallel requests all read the same count, all passed the check, and all inserted.
-- supabase/migrations/0007_atomic_download_rate_limit.sql
-- This RPC does the count + conditional insert in ONE call, serialized per user
-- with a transaction-scoped advisory lock, so concurrent requests for the same
-- user queue instead of racing.
Prisma can express this — an interactive transaction with the same pg_advisory_xact_lock in raw SQL. What it cannot do is put the guarantee somewhere the application cannot route around, which is the same distinction as the policies above: with the RPC, EXECUTE is revoked from client roles, so the only way to record a download is the correct way.
So do they compose?
Yes, and the pairing is reasonable when you know what you are choosing: Prisma for the query layer and migrations, Supabase for hosting, auth, and storage — with authorization moved deliberately into application code and RLS treated as defence-in-depth rather than the enforcement mechanism. What is not reasonable is the version most guides describe, where RLS policies are written, an ORM is pointed at the same database, and everyone assumes both are working.
If you are choosing between them as backends, that is a different question and mostly not about Prisma: Supabase vs Neon covers Postgres-versus-Postgres and which of our code depends on the platform rather than the database. Supabase vs Appwrite covers the same permission question across two backends — roles versus a SQL predicate per row — where this post covers it across a backend and a library.
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
| Prisma over an RLS schema, connecting as the table owner | Every query returns every row; no error, no test failure | FORCE ROW LEVEL SECURITY, a non-owner role, and per-request identity — or move authorization into code deliberately |
| Prisma as a non-owner role, expecting policies to work | Every query returns nothing; looks like a data bug | auth.uid() is NULL on that connection; the policy cannot match |
| Assuming "we have RLS" means the app is safe | RLS is bypassed on any service-role or owner path | Audit which client each query uses; keep the bypass in one server-only file |
| Hand-written row interfaces for an untyped client | Compiles after a column rename; undefined at runtime | Generate types from the schema, or adopt an ORM that does |
| Enforcing a rate limit with read-then-insert | Parallel requests all pass the check | One atomic call, serialized per user |
| Treating "they're different categories" as the end of the analysis | The RLS decision is never made | Decide explicitly where authorization lives before wiring either |
Frequently asked questions
Can I use Prisma with Supabase?
Yes — Prisma connects to Supabase's Postgres like any other Postgres. The caveat is that your RLS policies will not evaluate as written, because Prisma's connection carries no per-request user identity for auth.uid() to read.
Is Supabase an ORM? No. Supabase's client is a query builder over PostgREST that speaks HTTP and forwards the user's JWT. Prisma is a code-generating ORM that opens a database connection. The JWT-forwarding part is what makes row-level security usable from the client tier.
Which is better for a solo developer shipping fast? If authorization is the hard part of your app — anything multi-tenant, anything where users own rows — the policy-in-the-database model removes a whole class of mistake, and that is why this storefront uses it. If your schema is mostly public data and you feel the pain in query ergonomics and refactoring, Prisma's generated types are worth more.
Does using Prisma mean I lose Supabase Auth and Storage? No, those are separate services and keep working. You lose the property that a query made with a user's session can only see that user's rows.
Templates in this post
ASoc Mind is an AI marketing-solutions site with a services grid and an achievements band; ASoc Momentum is an AI-consulting agency site built around case studies and a contact funnel; ASoc Neuron markets a neural-network platform with a six-capability grid and three-tier pricing. All three are the front end you would put in front of a decision like this one.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the Postgres-versus-Postgres version of this question, read Supabase vs Neon; for the same permission model compared against another backend, read Supabase vs Appwrite.
