RowWarden
Sample on a fictional project. Not a customer.

This sample was produced by RowWarden's AI agents. On a paid Fix Pack, Scott F., a human, signs off before you see it.

Sample Fix Pack

What the $79 Fix Pack looks like

You run two free read-only queries in your Supabase SQL Editor and email us the output. You get back a triage of every row, one migration file, and what to test. This page is that whole exchange for a made-up habit tracker app, with real output from our free queries run on a local copy of it.

What the sample developer sent

Output of the free RLS audit query (rowwarden-rls-audit.sql), 10 rows:

 severity |            check            |                      object                      |                                                                                                               why_it_matters                                                                                                                |                                                                                 fix                                                                                 
----------+-----------------------------+--------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------
 high     | policy_using_true           | public.check_ins policy "check_ins_update"       | Any signed-in user can save any values in the rows they can update, for example handing a row to another user.                                                                                                                              | Replace the literal true in WITH CHECK with a condition tied to the row owner, such as (select auth.uid()) = user_id, or restrict the policy to a server-side role.
 high     | rls_disabled                | public.invoices                                  | RLS is off, so anyone with your public API key can read and change every row in this table.                                                                                                                                                 | Run: ALTER TABLE public.invoices ENABLE ROW LEVEL SECURITY; then add policies for the access you want.
 medium   | policy_not_tied_to_caller   | public.habits policy "habits_select"             | Its only condition is that the caller is signed in, so any signed-in user can read other users' rows.                                                                                                                                       | Replace the signed-in test with a condition tied to the row owner, such as (select auth.uid()) = user_id, or confirm every signed-in user should have this access.
 medium   | policy_using_true           | public.plans policy "plans_public_read"          | Anyone with your public API key can read every row, so the table is effectively public.                                                                                                                                                     | Replace the literal true in USING with a condition tied to the row owner, such as (select auth.uid()) = user_id, or restrict the policy to a server-side role.
 medium   | policy_using_true           | public.team_members policy "team_members_select" | Any signed-in user can read every row, other users' rows included.                                                                                                                                                                          | Replace the literal true in USING with a condition tied to the row owner, such as (select auth.uid()) = user_id, or restrict the policy to a server-side role.
 medium   | security_definer_executable | public.get_team_stats(team uuid)                 | This function runs with its owner's rights and can be called by anon and authenticated through the API, so it can bypass RLS.                                                                                                               | Revoke access with REVOKE EXECUTE ON FUNCTION public.get_team_stats(team uuid) FROM PUBLIC, anon, authenticated; or switch it to SECURITY INVOKER.
 medium   | storage_policy_using_true   | storage.objects policy "exports_read"            | Any signed-in user can download every file in every bucket, private buckets included.                                                                                                                                                       | Replace the literal true in USING with a condition on the bucket and the file owner, such as bucket_id = 'avatars' AND owner_id = (select auth.uid()::text).
 medium   | view_not_security_invoker   | public.leaderboard                               | A view runs with its owner's rights by default, so anyone with your public API key can use it to see rows that RLS on the underlying tables would hide.                                                                                     | Run: ALTER VIEW public.leaderboard SET (security_invoker = true);
 low      | rls_no_policies             | 1 table in public                                | This table has RLS on and no policies, so anon and signed-in users get nothing from it through the API and only server-side roles can use it. That is right for server-only tables and a bug for anything the app reads or writes directly. | Confirm it is meant to be server-only: webhook_events. If the app should read or write it, add a policy scoped to the owning user.
 low      | storage_bucket_public       | storage bucket "avatars"                         | Anyone with the file URL can download files in this bucket without logging in.                                                                                                                                                              | Confirm this is intended; if not, make the bucket private and serve files with signed URLs.
(10 rows)

Output of the free grant check (rowwarden-grant-check.sql, same repository), 3 rows:

  status   |       check       |        object        |                                                                                                    detail                                                                                                    |                                                                                                                                                                              fix                                                                                                                                                                              
-----------+-------------------+----------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 fix_first | rls_off_reachable | public.invoices      | Reachable through the API (anon: SIUD, authenticated: SIUD, service_role: SIUD) with row level security off. The October 30 change leaves existing tables as they are.                                       | alter table public.invoices enable row level security;  -- then add policies tied to the signed-in user
 check     | no_api_grant      | public.reminders     | No grant for authenticated or service_role (anon: none, authenticated: none, service_role: none). Server code using the service role key through supabase-js or the REST API gets "permission denied" on it. | grant select, insert, update, delete on public.reminders to service_role; grant usage, select on sequence public.reminders_id_seq to service_role; -- optional, only if signed-in users should reach it: grant select, insert, update, delete on public.reminders to authenticated; grant usage, select on sequence public.reminders_id_seq to authenticated;
 mode      | auto_grant        | new tables in public | New behaviour: new tables created by postgres get no API grants until you add them. (Migrations that run as another role follow that role's default privileges.)                                             | Every table you create needs explicit grants, e.g. grant select, insert, update, delete on public.<table> to authenticated; plus grant usage, select on sequence <its sequence> for serial columns.
(3 rows)

What came back, part 1: triage

Two of your output rows point at the same table (public.invoices), one from each query, so they share a fix.

Each of the 13 rows gets a verdict: a real problem, something that needs your decision, deliberate, or information only.

#objectcheckverdictwhyfix
1public.invoicesrls_disabled (audit, high)Real problemRow Level Security is off on your billing table and the public API roles can reach it, so anyone holding your public anon key (it ships in your web app) can read and change every invoice: amounts, status, and the hosted invoice URLs. No login needed. This is the highest-impact item.migration.sql, Triage #1
2public.invoicesrls_off_reachable (grant, fix_first)Real problemSame table, seen from the grant query. It confirms anon, authenticated, and service_role can all SELECT/INSERT/UPDATE/DELETE it with RLS off. The October 30 grant change does nothing for a table that already has grants, so you must turn RLS on yourself.migration.sql, Triage #1 (same fix)
3public.check_ins policy "check_ins_update"policy_using_true (audit, high)Real problemThe UPDATE policy checks ownership on the way in but its WITH CHECK is the literal true, so an update is not held to the owner check: it can change who owns the row (its user_id), not just its contents.migration.sql, Triage #3
4public.habits policy "habits_select"policy_not_tied_to_caller (audit, medium)Real problemThe read policy only checks that the caller is signed in, so any logged-in user can read every user's habit list, not just their own.migration.sql, Triage #4
5storage.objects policy "exports_read"storage_policy_using_true (audit, medium)Real problemThe download policy is USING (true) for signed-in users, so any logged-in user can download every file in every bucket, including the private exports bucket. One thing to watch when we scope this to the file owner: files your server uploads with the service role key often have no owner recorded, so an owner-only policy would hide them from the user who should see them. The fix note in the migration covers how to handle that.migration.sql, Triage #5
6public.leaderboardview_not_security_invoker (audit, medium)Real problemThe view runs with its creator's rights, so it ignores the RLS on check_ins underneath it and shows every user's check-in totals to anyone who can query the view. Trade-off to know: the fix (security_invoker) makes the view run with each caller's rights, so a signed-in user then sees only their own total and it stops being a cross-user leaderboard. If you want a real leaderboard that shows everyone, use the commented aggregate-function option in the migration, which exposes only the totals you choose and no personal fields. security_invoker needs Postgres 15 or later (Supabase runs 15 and 17).migration.sql, Triage #6
7public.get_team_stats(team uuid)security_definer_executable (audit, medium)Real problemThis function runs with its owner's rights and anon and signed-in users can call it with any team id, so it returns member and check-in counts for teams the caller has nothing to do with, bypassing RLS. The fix revokes the ability to call it from anon and signed-in users, so after it is applied any page in your app that calls get_team_stats from the client will get "permission denied". If one of yours does, use the commented alternative in the migration (keep it callable but scope it to the caller's own teams) instead of the revoke.migration.sql, Triage #7
8public.remindersno_api_grant (grant, check)Real problemThe table has RLS and a correct owner policy but no API grants, so your server (service role key) and your signed-in users both get "permission denied" when they touch it. The app feature is broken. This is a working-code fix, not a leak.migration.sql, Triage #8
9public.team_members policy "team_members_select"policy_using_true (audit, medium)Needs your callThe read policy is USING (true) for signed-in users, so any logged-in user can list who belongs to every team. That is a leak if team rosters are meant to be private, and fine if they are public. The right fix depends on your intended access model.none (see "Needs your call" and the commented options in migration.sql)
10storage bucket "avatars"storage_bucket_public (audit, low)Needs your callThe avatars bucket is public, so anyone with a file URL can download an avatar without logging in. Common and fine for profile pictures on public pages; wrong if these images should be private.none (see "Needs your call")
11public.plans policy "plans_public_read"policy_using_true (audit, medium)DeliberateA table called plans with only name and price_cents, read-only to anon. This is the standard public pricing-lookup pattern. Left as is.none
12public.webhook_eventsrls_no_policies (audit, low)DeliberateRLS is on with no policies, which is exactly right for a server-only table (only your service role key, which bypasses RLS, can use it). The name says it holds incoming webhook payloads. Left as is.none
13new tables in publicauto_grant (grant, mode)Info, no actionNot a finding. It tells you this project already follows the post-October-30 behaviour: new tables in public get no API grants until you add them. This is why reminders (#8) had none. Keep granting new tables explicitly going forward.none

Needs your call

Two items we cannot decide for you. Answer these and we will finish them. One follow-up round to complete these needs-your-call items is included in the $79, so you do not pay again for the final versions.

  1. team_members (#9): should every signed-in user be able to see who belongs to every team, or should a team's roster be visible only to that team's members (and owner)? If rosters are private, we scope the policy to teams you belong to. If they are meant to be public to logged-in users, we leave it and mark it deliberate. migration.sql has both options commented out, ready to uncomment. Note: a policy that lets members see their co-members has to read team_members from inside a team_members policy, which Postgres rejects as recursion, so that one needs a small helper function. We will write it once you confirm you want the co-members view.
  2. avatars bucket (#10): are avatar images meant to be downloadable by anyone with the link (no login)? If avatars appear on public profile pages, yes, leave it public. If they should only be visible to signed-in users, make the bucket private and serve files through signed URLs. Tell us which and we will include the change. The migration has this as a commented option.

Reading the free tool's wording

A few spots in the free query output can read oddly out of context. None change the fixes above:

Not covered

These two queries only read your database catalog. They cannot see your API routes, edge functions, API keys, Stripe webhooks, or your auth settings, so a clean result here is the floor, not a full assessment of the app. Those live in the $249 full review (rowwarden.com).

Part 2: one migration file

One transaction, worst items first, in triage order. The deliberate items are left alone and the two "needs your call" items are commented-out options, so nothing changes until you choose.

-- SAMPLE RLS Fix Pack migration for the fictional "Demo habit tracker". Not a customer system.
-- Review it, run it on a branch or a copy first, then apply with the tests in TESTS.md.
-- One transaction: it all applies or none of it does. Statements are in triage order (worst first).
-- Deliberate items (plans, webhook_events) are left untouched on purpose. Needs-your-call items
-- (team_members, avatars) are left as commented-out options below, so nothing changes until you choose.

begin;

-- Triage #1 / #2 (public.invoices, high): turn RLS on so the public key can no longer read or change
-- every invoice. Your server writes invoices with the service role key, which bypasses RLS, so no
-- write policy is needed here. Signed-in users may read only their own invoices.
alter table public.invoices enable row level security;

create policy invoices_select_own on public.invoices
  for select to authenticated
  using (user_id = (select auth.uid()));

-- Triage #3 (public.check_ins policy "check_ins_update", high): keep the ownership check but also
-- block a user from reassigning a check-in to a different user_id on the way in.
alter policy check_ins_update on public.check_ins
  with check (user_id = (select auth.uid()));

-- Triage #4 (public.habits policy "habits_select", medium): a signed-in user may read only their own
-- habits, not everyone's.
alter policy habits_select on public.habits
  using (user_id = (select auth.uid()));

-- Triage #5 (storage.objects policy "exports_read", medium): a signed-in user may download only their
-- own files in the private exports bucket, not every file in every bucket.
-- Watch out: files your server uploads with the service role key often have no owner recorded
-- (owner_id is null), so this owner-only rule would hide them from the user. Two ways to handle it:
--   (a) set the owner when your server uploads the file, so owner_id is the user's id; or
--   (b) scope by a path prefix instead, for example store each user's exports under a folder named with
--       their id and match on that:
--         using (bucket_id = 'exports' and (storage.foldername(name))[1] = (select auth.uid()::text))
-- The owner_id version below is the common case. Pick the one that matches how you upload.
alter policy exports_read on storage.objects
  using (bucket_id = 'exports' and owner_id = (select auth.uid()::text));

-- Triage #6 (public.leaderboard, medium): make the view run with the caller's rights so the RLS on
-- check_ins underneath it applies. (security_invoker needs Postgres 15 or later; Supabase runs 15 and 17.)
-- Trade-off: after this, a signed-in user sees only their own total through the view, so it is no longer a
-- cross-user leaderboard. That is the safe default. If you actually want a leaderboard that shows everyone,
-- do NOT rely on this view. Use a function that returns only the columns you choose to publish (no user_id
-- or other personal fields), like the commented option further down.
alter view public.leaderboard set (security_invoker = true);

-- Triage #7 (public.get_team_stats, medium): stop anon and signed-in users from calling this
-- owner-rights function with any team id. Your server keeps access through the service role key.
-- After this runs, any page in your app that calls get_team_stats from the client (as a signed-in user)
-- will get "permission denied". If one of yours does, do NOT run this revoke; use the commented
-- alternative at the bottom of this file instead, which keeps it callable but only for the caller's own
-- teams.
revoke execute on function public.get_team_stats(uuid) from public, anon, authenticated;

-- Triage #8 grant fix (public.reminders): give the API roles the access the owner policy already
-- expects. service_role is for your server code; authenticated is because the reminders_owner policy
-- lets signed-in users manage their own reminders directly. anon gets nothing on purpose.
grant select, insert, update, delete on public.reminders to service_role, authenticated;
grant usage, select on sequence public.reminders_id_seq to service_role, authenticated;

commit;

-- ----------------------------------------------------------------------------------------------------
-- NEEDS YOUR CALL: nothing below runs. Move the chosen lines above the commit line after you decide.
-- ----------------------------------------------------------------------------------------------------

-- Triage #9 (public.team_members policy "team_members_select"): should every signed-in user see who is
-- on every team? Pick one.
--
--   Option A: each user sees only their own membership rows.
--     alter policy team_members_select on public.team_members
--       using (user_id = (select auth.uid()));
--
--   Option B: a team owner sees the full roster of teams they own (members who are not owners see only
--   their own row, as in Option A). No recursion because it reads public.teams, not team_members.
--     alter policy team_members_select on public.team_members
--       using (
--         user_id = (select auth.uid())
--         or team_id in (select id from public.teams where owner_id = (select auth.uid()))
--       );
--
--   Option C (every member sees their co-members): this must read team_members from inside a
--   team_members policy, which Postgres rejects as recursion, so it needs a small SECURITY DEFINER
--   helper. Tell us you want Option C and we will write the helper and the policy for you.

-- Triage #7 (public.get_team_stats), only if your app calls this function from the client as a signed-in
-- user. The revoke above locks it to the server. If signed-in users must call it, keep their access but
-- return stats only for teams they belong to. Replace the revoke above with a rewrite like this (we will
-- finalise it once the team_members model in #9 is decided):
--   -- (leave the revoke out, then)
--   -- create or replace function public.get_team_stats(team uuid) ... security invoker ...
--   --   with a membership check so a caller only gets counts for a team they are a member of.

-- Triage #6 (public.leaderboard), only if you want a real leaderboard that shows every user's totals to
-- everyone. The security_invoker fix above makes the view private to each caller, so it is not a public
-- leaderboard any more. If you want a shared one, publish it through a function that returns ONLY the
-- columns you are happy to show (here display_name and the total, no user_id or other personal fields),
-- and grant execute to just the roles that need it. Decide this is what you want first, then:
--   create or replace function public.leaderboard_public()
--     returns table (display_name text, total_check_ins bigint)
--     language sql
--     stable
--     security definer
--     set search_path = ''
--     as $$
--       select p.display_name, count(*) as total_check_ins
--       from public.check_ins c
--       join public.profiles p on p.id = c.user_id
--       group by p.display_name
--     $$;
--   revoke execute on function public.leaderboard_public() from public, anon;
--   grant execute on function public.leaderboard_public() to authenticated;

-- Triage #10 (storage bucket "avatars"): only if avatar images should NOT be downloadable by just anyone
-- with the link. Make the bucket private, then serve files through short-lived signed URLs from your app
-- (for example supabase.storage.from('avatars').createSignedUrl(path, 60)). Leave it out to keep avatars
-- public, which is normal for profile pictures on public pages.
--   update storage.buckets set public = false where id = 'avatars';

Part 3: what to test

What to check after you apply migration.sql. Run it on a branch or a copy of your project first. These steps use two test accounts, user A and user B, each signed in to get a real authenticated token. "Through the API" means a normal PostgREST/supabase-js call with your anon key, not the SQL editor.

1. Signed-out (anon) requests are blocked where they should be

  1. With no login (anon key only), request invoices. Expect zero rows. Before the fix this returned every invoice.
  2. With no login, request habits and check_ins. Expect zero rows.
  3. With no login, request plans. Expect your pricing rows to still come back. This is the deliberate public lookup and your pricing page depends on it.
  4. Open a known avatars file URL in a private browser window. Expect it to download (public bucket, unchanged).
  5. Open a known exports file URL without logging in. Expect it to fail. Only the owner, signed in, reaches it now.

2. Signed-in as user A, you cannot see user B's data

  1. As A, request habits. Expect only A's habits. Repeat as B; expect only B's.
  2. As A, request check_ins. Expect only A's rows.
  3. As A, request invoices. Expect only A's invoices. Then write to invoices as A through the API. These behave differently, which is normal for a table with no write policy: an INSERT is refused with a row-level security error, while an UPDATE is not refused but changes zero rows (no existing row matches the missing write policy), so it comes back as success with a count of 0. Re-read the invoice afterward to confirm nothing actually changed. Invoices are written by your server (service role key), not by signed-in users.
  4. As A, update one of your own check_ins rows and try to set its user_id to B's id. Expect it to be refused with a row-level security error, because the new user_id fails the WITH CHECK. Re-read the row to confirm it is unchanged. Before the fix this succeeded and moved the row to B.
  5. As A, query the leaderboard view. Expect only A's own total, not every user's. (The view now runs with your rights, so the check_ins policy applies.)
  6. As A, call get_team_stats through the API. Expect "permission denied". Your server, using the service role key, can still call it.
  7. As A, list and download files in the exports bucket. Expect only A's own files.
  8. Server-written exports: have your server (service role key) upload a file to the exports bucket for user A, then sign in as A and download it. Expect it to work. If A cannot see it, the file was stored with no owner recorded, so switch to one of the two options in migration.sql Triage #5 (set the owner at upload time, or scope the policy by a user-id path prefix) and re-test.

3. The reminders feature works again

  1. As signed-in user A, INSERT a row into reminders with user_id = A. Expect success (this is the serial-id + grant fix; before the migration it failed with "permission denied for table" or "permission denied for sequence").
  2. As A, try to INSERT a reminders row with user_id = B. Expect it to be refused with a row-level security error (the owner policy's WITH CHECK blocks it).
  3. From your server code (service role key), INSERT and SELECT a reminder. Expect success.

4. Your own pages still load

  1. Pricing page (reads plans as anon): still loads.
  2. Profile page (A reads and updates A's own profiles row): still works.
  3. Teams you own (A manages A's own teams rows): still works.
  4. Avatar images on profile pages: still load.

After the migration

After the migration, the two free queries should return only these rows. Everything you fixed is gone.

The grant query's "mode" row and the reminders fix come from Supabase's October 30, 2026 change to default grants: what changed and what to do.

Audit query again, 4 rows left:

 severity |         check         |                      object                      |                                                                                                               why_it_matters                                                                                                                |                                                                              fix                                                                               
----------+-----------------------+--------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------
 medium   | policy_using_true     | public.plans policy "plans_public_read"          | Anyone with your public API key can read every row, so the table is effectively public.                                                                                                                                                     | Replace the literal true in USING with a condition tied to the row owner, such as (select auth.uid()) = user_id, or restrict the policy to a server-side role.
 medium   | policy_using_true     | public.team_members policy "team_members_select" | Any signed-in user can read every row, other users' rows included.                                                                                                                                                                          | Replace the literal true in USING with a condition tied to the row owner, such as (select auth.uid()) = user_id, or restrict the policy to a server-side role.
 low      | rls_no_policies       | 1 table in public                                | This table has RLS on and no policies, so anon and signed-in users get nothing from it through the API and only server-side roles can use it. That is right for server-only tables and a bug for anything the app reads or writes directly. | Confirm it is meant to be server-only: webhook_events. If the app should read or write it, add a policy scoped to the owning user.
 low      | storage_bucket_public | storage bucket "avatars"                         | Anyone with the file URL can download files in this bucket without logging in.                                                                                                                                                              | Confirm this is intended; if not, make the bucket private and serve files with signed URLs.
(4 rows)

Which rows remain and why:

Grant check again, 1 row left:

 status |   check    |        object        |                                                                              detail                                                                              |                                                                                                 fix                                                                                                 
--------+------------+----------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 mode   | auto_grant | new tables in public | New behaviour: new tables created by postgres get no API grants until you add them. (Migrations that run as another role follow that role's default privileges.) | Every table you create needs explicit grants, e.g. grant select, insert, update, delete on public.<table> to authenticated; plus grant usage, select on sequence <its sequence> for serial columns.
(1 row)

"new tables in public" (auto_grant, mode). Informational, not a finding. It always shows while your project uses the post-October-30 behaviour; it is not something to fix.

Everything else (invoices, check_ins, habits, exports, leaderboard, get_team_stats, and the reminders grant row) should no longer appear.

$79 RLS Fix Pack

Get this for your project: $79

  • Run the free, read-only RLS audit in your SQL Editor
  • Email us the output; we never touch your project
  • Ranked triage: what is real, what is deliberate
  • A ready-to-run SQL migration for your tables
  • Within two business days of payment; full refund if it isn't useful
Get the Fix Pack: $79

Questions first? rowan@rowwarden.com

The queries only read your database catalog. For the parts they cannot see (API routes, edge functions, keys, auth settings), there is the $249 full review.