← All posts
·8 min read·GuardLayer

Case study: how an "own row" Supabase policy opened other accounts

SupabaseRLSPostgresGrantsCase study

A policy like using (id = auth.uid()) controls which row a user can write. It doesn't control which columns. In LabStock, an open-source inventory app on Supabase, that gap let any signed-in user set one column on their own profile and get read access to another user's private inventory. If other policies trust a column on a table users can edit, users must not be able to write that column. Make that column read-only for users with column grants or a trigger.

We reported this privately to LabStock on October 22, 2026. Recep, who builds it, confirmed it and put a fix live on the hosted project on November 7. This write-up is published with his permission. LabStock is at labstock.com.tr.

What LabStock does

LabStock keeps track of parts, stock, suppliers, projects and bills of materials. Our favourite feature is counting with the phone camera: pour the parts on the table and the camera counts them, with a coin next to them as a size reference if you want a more accurate count.

It also has an observer feature (gözlemci in Turkish, which is where the column names below come from). You give someone your invite code, and they get a read-only view of your inventory and can help with your shopping list. A function called gozlemci_baglan() handles the link. It checks the invite code, blocks linking to yourself, and allows at most eight observers.

How did the bug work?

The link is stored on the observer's own profile row, in a column called gozlemci_of ("observer of"). Policies on the inventory tables check that column to decide who can read what. Simplified:

-- Users manage their own profile row.
create policy profiles_self on public.profiles
  for all to authenticated
  using (id = auth.uid()) with check (id = auth.uid());

-- Observers can read the inventory of the account they observe.
create policy stock_observer_read on public.stock_items
  for select to authenticated
  using (exists (
    select 1 from public.profiles p
    where p.id = auth.uid() and p.gozlemci_of = stock_items.owner
  ));

Each policy looks fine on its own. The problem is the combination.

profiles_self pins writes to the user's own row. But LabStock's tables were created with Supabase's old default grants, which give the authenticated role UPDATE on the whole table. So a user could change any column on their row, gozlemci_of included. Since October 30, 2026, new tables get no grants automatically. The usual fix for the resulting 42501 error, grant select, insert, update, delete on public.profiles to authenticated, puts the same table-wide privilege back. The invite-code check, the self-link block and the eight-observer limit only exist inside gozlemci_baglan(). A plain update to the profile row skips all three:

await supabase.from('profiles')
  .update({ gozlemci_of: '<another user id>' })
  .eq('id', myUserId)

Policies check the request, not which path in your app it came from, so this request passes. From then on, the observer policies treat the user as an observer of that account.

The other half is finding a user ID to point at. In LabStock, the shared parts catalogue was readable by every signed-in user, and each part row stored the ID of the user who created it. So IDs were easy to collect.

What did it expose?

Someone with a free account on the hosted project could have seen another user's locations, projects, stock items with quantities, prices, suppliers and notes, movement history, BOMs and tags. They could also add, edit and delete items on that user's shopping list. They wouldn't have needed any special tools, because the app's normal observer view showed all of it. The only trace was the attacker's name in the victim's observer list while the link was active.

Some things were safe. The users' AI API keys, stored in their own table, had no observer policy, so they weren't exposed.

We found this by reading the public source. We didn't test anything against the live app or its database.

How was it fixed?

LabStock fixed it with a trigger on the profiles table and reviewed the existing observer links. There are two standard ways to close this kind of hole. We tested both against a copy of the schema in Postgres.

Option 1: column grants. Take away the table-wide write privilege and grant back only the columns users may edit:

revoke insert, update on public.profiles from anon, authenticated;
grant insert (id, ad, telefon, davet_kodu) on public.profiles to authenticated;
grant update (ad, telefon, davet_kodu) on public.profiles to authenticated;

A column-level revoke update (gozlemci_of) on its own does nothing while the table-wide grant is still there, which is why the revoke comes first. The details are in Supabase column-level security. gozlemci_baglan() is SECURITY DEFINER, so it can still set the column. Users then clear the link through a second small function instead of a direct update.

Option 2: a trigger. Let users clear the link but never set it themselves:

create function public.guard_observer_link() returns trigger
language plpgsql as $$
begin
  -- Inside a SECURITY DEFINER function, current_user is the function's owner,
  -- so gozlemci_baglan() can still set the link.
  if current_user in ('authenticated', 'anon')
     and new.gozlemci_of is not null
     and (tg_op = 'INSERT' or new.gozlemci_of is distinct from old.gozlemci_of) then
    raise exception 'Observer links can only be set with an invite code';
  end if;
  return new;
end $$;

create trigger guard_observer_link
  before insert or update on public.profiles
  for each row execute function public.guard_observer_link();

The trigger is easier to add to a working app, because the app's other updates to the profile keep working unchanged. Column grants are stricter: a column you add later is read-only until you grant it. Either way, look at the existing links after the fix, because a fix doesn't undo a link someone already made.

Does my app have the same problem?

Look for any column that does both of these things:

  1. It sits on a table where users can update or insert their own row, usually profiles.
  2. Another policy reads it to decide access.

Role and plan columns are the well-known case (see where roles belong so users can't change them). Link columns are easier to miss, because their names say nothing about permissions: team_id, org_id, manager_of, linked_account, shared_with, observer_of.

This query lists the columns users can update that a policy on another table mentions. Run it in the Supabase SQL editor:

with writable as (
  select c.relname as table_name, a.attname as column_name
  from pg_attribute a
  join pg_class c on c.oid = a.attrelid
  join pg_namespace n on n.oid = c.relnamespace
  where n.nspname = 'public' and c.relkind in ('r', 'p')
    and a.attnum > 0 and not a.attisdropped
    and a.attname not in ('id', 'user_id', 'created_at', 'updated_at')
    and has_column_privilege('authenticated', a.attrelid, a.attnum, 'UPDATE')
)
select w.table_name, w.column_name, p.tablename as checked_by_table, p.policyname
from writable w
join pg_policies p
  on p.schemaname = 'public'
 and p.tablename <> w.table_name
 and concat_ws(' ', p.qual, p.with_check) ~ ('\m' || w.column_name || '\M')
 and concat_ws(' ', p.qual, p.with_check) ~ ('\m' || w.table_name || '\M')
order by 1, 2;

On the LabStock-style schema above it returns profiles.gozlemci_of, checked by stock_observer_read, and after either fix it returns nothing. A row in the result needs a closer look, not necessarily a fix. A row only matters if a policy also lets users write their own row in that table, and the query can't see access checks hidden inside helper functions such as is_member(). Read those functions too.

Would GuardLayer have caught it?

Not when we found it. GuardLayer's Live check flagged users who could change their own role, plan or credits, and it found those columns by name. A link column like gozlemci_of has no telltale name, so it slipped past. We found this one by reading the code.

So we changed the check. Live check now looks at structure instead of names: a column users can write on their own row that another policy reads through a lookup of that row. On the LabStock-style schema it reports profiles.gozlemci_of as critical, names the policy that trusts it, and gives the column-grant fix. Like the query above, it can't see a lookup hidden inside a helper function, and it skips columns compared straight to auth.uid(), such as the second participant in a chat, because those give access to the person named, not to the writer.

FAQ

Doesn't with check (id = auth.uid()) stop this? No. with check validates the row after the update: is this still your own row? It says nothing about which columns changed. To limit columns, use column grants or a trigger.

Is this a Supabase bug? No. RLS works as documented: it filters rows. A table-wide grant gives authenticated access to every column, and the Supabase docs describe column privileges for the case where one column needs its own rules. The same gap exists in any Postgres app that uses RLS.

Why not check the invite code in the policy instead? A policy can't easily tell which code path a write came from. Keep the check in one SECURITY DEFINER function, and make sure that function is the only way to write the column.

How do I check whether someone already used a hole like this? Look at the data the hole would create. For LabStock, that means every observer link not made through an invite code. If you log those links, compare against the log. If you don't, ask the account owners to confirm their observer lists.

See which of your Supabase tables anyone can read, free

Connect your Supabase project read-only. GuardLayer checks RLS, policies, grants and functions as they are right now, and gives you the SQL to fix each problem. It never reads your rows and can't change anything.

Check my Supabase project

Prefer to check code instead? Scan your repo, no signup.

Keep reading