Break the chains: your RLS policies can leave Supabase
Row-level security is usually the reason a project cannot move off Supabase - not the policies, but the auth context under them. capyrls converts that layer to plain Postgres, in your terminal or in your browser, and tells you what it could not convert.
The chain is not the policies
Ask a team why they are still on Supabase after deciding to leave, and the answer is rarely the
data. pg_dump moves data. Auth has a known path to Clerk, Auth.js or your own. What stops them is
row-level security.
Not because the policies are exotic. Supabase RLS is ordinary CREATE POLICY. What is not ordinary
is the context those policies read: auth.uid(), auth.jwt(), auth.role(), the anon,
authenticated and service_role roles, and PostgREST putting a verified JWT into a session
setting on every request. None of that exists on plain Postgres. Restore a Supabase dump anywhere
else and each policy either stops the restore at the first missing function or role, or loads and
then never matches a row.
So the choice looks like this: rewrite your authorization model as application code, or stay. When the policies are the authorization model - an app that builds anon-key clients and lets RLS decide - rewriting means re-deriving every rule by hand, across every call site, and hoping you got them all. Most teams stay.
That context layer is the part capyrls converts. The policies stay in the database, doing the same work, with no Supabase left in them.
Before and after
A table with two policies, as Supabase migrations usually write them:
create policy "own todos" on public.todos
for all to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());
create policy "org members read" on public.todos
for select to authenticated
using (org_id = (auth.jwt() ->> 'org_id')::uuid);What capyrls writes for them (trimmed; the delete and update policies follow the same shape):
create policy "org members read" on public.todos
for select
using ((select app.user_id()) is not null and (org_id = ((select app.org_id()))::uuid));
create policy "own todos_select" on public.todos
for select
using ((select app.user_id()) is not null and (owner_id = (select app.user_id())));
create policy "own todos_insert" on public.todos
for insert
with check ((select app.user_id()) is not null and (owner_id = (select app.user_id())));
-- Column defaults that referenced auth.*
alter table public.todos alter column owner_id set default app.user_id();Line by line:
auth.uid()becomesapp.user_id(), astablefunction over a transaction-local setting, wrapped in(select ...)so Postgres evaluates it once per statement instead of once per row.auth.jwt() ->> 'org_id'becomesapp.org_id(). Every claim your policies read gets its own accessor and its own setting, so the database no longer parses a JWT at all.TO authenticatedbecomes "a user id is set".TO anonbecomes "no user id is set".FOR ALLis split into one policy per command, so each can be read, tested and changed on its own (--keep-for-allturns that off).- The column default that called
auth.uid()is rewritten too, which is easy to miss by hand.
Your application verifies the caller at the edge and states the facts per transaction:
begin;
select set_config('app.user_id', '5f4d...', true);
select set_config('app.org_id', '9c1e...', true);
select * from public.todos;
commit;set_config(..., true) is SET LOCAL: it resets at commit or rollback, so it is safe behind a
transaction pooler. An unset value reads as NULL, and every policy fails closed. The report that
comes with the bundle lists each setting your app must provide; that table is your integration
checklist. With drizzle, withAuthContext from @capydb/drizzle does the set_config calls for
you.
Two conventions
Vanilla is the default, shown above: a small app.* schema of accessors over plain settings.
It is the idiomatic plain-Postgres shape, and it is where we would like you to end up.
Compat (--mode supabase-compat) is the lift-and-shift. It emits an auth.* shim backed by
request.jwt.claims, the same setting PostgREST used, and ports your policies verbatim. Your app
sets the verified claims object per transaction and the policies read it exactly as before. Ship
that first if the cutover is already a lot of moving parts; move to vanilla when things are calm.
As of 1.15.0 compat mode also has a service path. On Supabase, server code that held the service
key bypassed RLS. In compat mode, a transaction whose verified claims carry
"role": "service_role" passes a service escape on every table. Set that role only from server
code that used the service key, never from a token your users can shape: a role claim copied from
user input is a bypass of every policy. --no-service-escape removes it.
One credential, and FORCE
On CapyDB your app connects as the credential that owns its tables. Postgres lets an owner bypass
RLS unless the table is FORCEd, so the default on CapyDB is the single role model:
FORCE ROW LEVEL SECURITY on every converted table, plus the service escape for seeds, backfills
and admin jobs (app.role = 'service' in vanilla mode, the claim above in compat mode). The escape
is also ORed into restrictive policies, because a permissive policy cannot lift a restrictive one,
and service_role skipped those on Supabase too. It adds convenience, not exposure: an owner can
disable RLS anyway.
The classic alternative - a runtime role that owns nothing, and a separate BYPASSRLS role for
services - needs a database role that can create roles. A CapyDB database role cannot, and every
login CapyDB issues acts as the owner, so capydb migrate rls --role-model split stops before it
reads anything and says why, instead of producing a bundle that fails halfway through applying.
The standalone converter still builds the split model for a Postgres where you manage roles
yourself.
Update, 2026-09-30: a CapyDB project can now enable a platform-created runtime role, app_user,
and capydb migrate rls --role-model split builds a bundle for it - no roles created, the owner as
the service path. See role models.
User ids that are not uuids
Supabase user ids are uuids, and by default so is the converted accessor. If you are moving auth to
Clerk at the same time, your ids look like user_2abc..., and every policy would fail with
invalid input syntax for type uuid. --uid-type text makes the accessor return text. The columns
compared to it then have to be text too, and the report names each uuid column it can see, with
the alter column statement to run before you apply. It does not quietly cast the column side
instead: that predicate cannot use the column's index, and a uuid column cannot hold a Clerk id
anyway, so the policy would match nothing without telling anyone.
It tells you what it could not do
A converter that always succeeds is one you cannot trust. capyrls has a list of things it will not guess about, and it puts each one in the report instead of mistranslating it:
- Policies that read
auth.users. That table stays on Supabase. Port the columns you need into your own users table first, then rewrite the policy against it. - Policies on Supabase's own schemas -
storage.objects, realtime. They protect platform features that do not move with the database. - Function bodies that call
auth.*. Listed for review, with how many policies depend on each one. A corpus where most policies go through one custom helper looks converted at the policy level while the decision still readsauth.jwt(); the report says so. - Deep claim paths that could not be promoted to their own accessor fall back to the full claims blob and are flagged.
1.15.0 adds one that bites quietly. A SECURITY DEFINER function runs as the table owner, and on
Supabase that owner bypassed RLS - usually the reason the function is a definer: it writes a row
the caller's own policies would not allow, like accepting an invitation on someone else's behalf.
Under FORCE the owner is filtered like everyone else, so that write now fails with 42501, or
disappears into an exception when others handler and fails silently. The report lists every
definer that writes to a FORCEd table, with the fix for your mode: raise the service escape inside
the function body and restore it before returning. The obvious alternative, ALTER FUNCTION ... SET app.role, is not offered, because a managed database role is not allowed to set a custom parameter
on a function. Detection reads the function's own statements; a write built with EXECUTE is not
seen, so check dynamic SQL by hand.
In CI, the standalone binary's --strict flag fails the run when anything needs a human.
In your browser or your terminal
The fastest way to see what your policies turn into is the web converter. Paste your migrations or a schema dump and you get the bundle and the report. It is the same converter compiled to WebAssembly, and it runs in the page: nothing is uploaded.
With a CapyDB account, the CLI does the same and can read the live source database, which is the most faithful input because migration folders drift from what is deployed:
capydb migrate rls --source-url "$SUPABASE_DIRECT_URL"Without one, the standalone binary works against any Postgres destination - RDS, self-hosted, anywhere:
go install github.com/capydatabase/capyrls/cmd/capyrls@latest
capyrls convert supabase/migrationscapyrls is MIT-licensed and on GitHub. It is also a dependency-free Go library, if you would rather call it from your own tooling.
The output is plain SQL you apply with whatever runs your migrations. Read the report before you apply it, run a two-user test (user A reads a row owned by user B and gets nothing), and keep the old database until that test passes. The full guide covers the rest, including the foreign-key quirk FORCE introduces and how to count rows when policies are filtering your own view of them.
A year of Postgres, on us
Three growth programs, one mechanism. Startups get twelve months of Ship free, partners get 30% off across five client workspaces, and any workspace can refer another for free months on both sides. All of it is a discount on the ordinary plans - no special tier, no credit balance, no catch we could find.
A database is a service unit
CapyDB is built around database cells: isolated Postgres runtimes that give each project its own storage, WAL, credentials, socket boundary, pooler, extensions, and CPU/RAM envelope.