DEV Community

Cover image for Your Supabase users can probably give themselves your paid plan. Here's the 30-second test
LeakPatrol
LeakPatrol

Posted on Originally published at getleakpatrol.com AI-assisted

Your Supabase users can probably give themselves your paid plan. Here's the 30-second test

A lot of apps built with Lovable, Bolt or Cursor handle "upgrade to Pro" with one line in the browser:

await supabase.from("profiles").update({ is_pro: true }).eq("id", user.id);
Enter fullscreen mode Exit fullscreen mode

If that line works for your code, it works for anyone. Open DevTools, run the same thing in the console, and the paid plan is theirs. Same for plan, credits, role or trial_ends_at.

Why RLS doesn't stop it

The usual policy on a profiles table is "users can update their own row":

create policy "Users can update own profile"
on public.profiles for update
using (auth.uid() = id);
Enter fullscreen mode Exit fullscreen mode

That policy decides which rows a user may change. It says nothing about which columns. If billing columns live on that row, the user can change those too.

Deleting the update call from your frontend doesn't fix it either. The policy still allows the request, and anyone can write it by hand with your public anon key and their own login.

The 30-second test (on your own app)

  1. Log in as a normal free user.
  2. Open DevTools → Console and run:
await supabase.from("profiles")
  .update({ is_pro: true })
  .eq("id", (await supabase.auth.getUser()).data.user.id);
Enter fullscreen mode Exit fullscreen mode
  1. Reload. If you're Pro now, so is anyone who tries.

Use your own table and column names. Only test apps you own.

Fix 1: grant updates per column

Postgres can grant UPDATE on specific columns. RLS still picks the rows; the grant picks the columns.

revoke update on table public.profiles from anon, authenticated;
grant update (full_name, avatar_url) on table public.profiles to authenticated;
Enter fullscreen mode Exit fullscreen mode

List every column users should be able to edit. If users create their own profile row, do the same for insert, or someone can sign up with is_pro = true.

Fix 2: a trigger that guards billing columns

If you'd rather not touch grants:

create or replace function public.protect_billing_columns()
returns trigger language plpgsql as $$
begin
  if (new.is_pro is distinct from old.is_pro or new.plan is distinct from old.plan)
     and coalesce(auth.role(), '') <> 'service_role' then
    raise exception 'billing columns can only be changed by the server';
  end if;
  return new;
end $$;

create trigger protect_billing before update on public.profiles
for each row execute function public.protect_billing_columns();
Enter fullscreen mode Exit fullscreen mode

Fix 3 (cleanest): billing gets its own table

Put subscriptions or credits in a separate table with a select policy for the owner and no insert, update or delete policies. Only your payment webhook writes to it, from an Edge Function using the service_role key, which stays a server secret.

Only the webhook sets the plan

Your Stripe or Lemon Squeezy webhook should verify the signature and then set the plan on the server. Don't set it from the checkout "thank you" page: anyone can open that URL without paying.

If you build with an AI tool, this prompt works well in Lovable, Bolt or Cursor:

Users must not be able to change is_pro, plan or credits. Only the payment webhook, in an Edge Function that verifies the signature and uses the service role key, may set them. Remove all frontend code that writes these columns, revoke UPDATE on profiles from authenticated and grant it back only for the columns users may edit. Do not turn off RLS.

The last sentence matters. When the new rules break something, AI tools like to "fix" it by disabling RLS.

Checking a live app

I build LeakPatrol, a free scanner for AI-built apps. It now reads the JavaScript your live site loads and flags code that writes paid columns (is_pro, premium, plan, credits…) straight from the browser, along with leaked keys and readable Supabase tables. It can't see your policies, so run the console test above as well.

The full guide, with an FAQ, is on our site.

Top comments (0)