CapyDB/ docs
← All posts

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.

September 29, 2026CapyDB team

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() becomes app.user_id(), a stable function over a transaction-local setting, wrapped in (select ...) so Postgres evaluates it once per statement instead of once per row.
  • auth.jwt() ->> 'org_id' becomes app.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 authenticated becomes "a user id is set". TO anon becomes "no user id is set".
  • FOR ALL is split into one policy per command, so each can be read, tested and changed on its own (--keep-for-all turns 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 reads auth.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/migrations

capyrls 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.