A common AI-built setup: the Stripe webhook writes is_paid = true (or plan = 'pro') into a Supabase profiles table, and the app shows paid features when that column says so. The webhook can be perfectly verified and the paywall still leaks, because the same table usually has a policy that lets each user update their own row. Every column of it, including the one that says they paid.
anon) key are in the page's JavaScript. That part is normal: Supabase says the publishable key is for "Anything you ship: browser, mobile app, CLI, script" and "Anyone can read it, so it only reaches what Row Level Security allows" (Supabase docs: API keys).await supabase.from('profiles').update({ is_paid: true }).eq('id', (await supabase.auth.getUser()).data.user.id).select().is_paid: true, any user can unlock paid features without paying. Also try it signed out: if RLS is off on the table, it may work for anyone.Supabase's docs show the usual policy, "Allow update for owners", restricting updates with (select auth.uid()) = user_id, and then say: "However, this gives the post owner full access to update the row, including all of the columns." By default "the authenticated role can update all the columns in the table" (Supabase docs: Column Level Security). Row Level Security picks which rows a user can touch. It doesn't pick columns.
It gets worse when RLS was never turned on. Supabase warns: "A table in an exposed schema without RLS is readable and writable by any role with a grant on it", and on existing projects a new table in public "starts with every privilege already granted" to anon, authenticated and service_role. "Adding policies doesn't take those grants back" (Supabase docs: Row Level Security).
public. Supabase's procedure is to enable RLS, revoke the default grants from anon and authenticated, then grant back only what the app needs (source).plan, paid_until and the Stripe customer ID in their own table that signed-in users can only select, and only their own row.profiles, revoke the table-wide update and grant update only on the columns users may edit. Supabase's example: revoke update on table public.posts from authenticated; then grant update (title, content) on table public.posts to authenticated; (source). Note the docs' caution that restricted roles then "cannot use the wildcard operator (*)" on that table, so select('*') calls need explicit columns.service_role key) in frontend code. If it was ever in a bundle, rotate it.-- 1) A table only the server writes
create table public.entitlements (
user_id uuid primary key references auth.users (id) on delete cascade,
plan text not null default 'free',
stripe_customer_id text,
paid_until timestamptz
);
alter table public.entitlements enable row level security;
revoke all on table public.entitlements from anon, authenticated;
grant select on table public.entitlements to authenticated;
create policy "Users can read their own entitlement"
on public.entitlements for select
to authenticated
using ( (select auth.uid()) = user_id );
-- no insert/update/delete grant or policy for clients: only the webhook (secret key) writes here
-- 2) If is_paid must stay on profiles, stop users editing it
revoke update on table public.profiles from authenticated;
grant update (display_name, avatar_url) on table public.profiles to authenticated;
Pattern adapted from Supabase's RLS and column-level security docs (RLS, column privileges). Rename the columns to match your app.
The webhook side stays the same as in our other guides: verify the Stripe signature first (Supabase Edge Function setup), and unlock from the verified event, not the success page (why the success page can't be the unlock). Stripe's own fulfillment guide runs the unlock from your server with your secret key (Stripe docs: Fulfill orders).
Users must not be able to give themselves paid access. 1) Enable Row Level Security on every table in the public schema, revoke all grants from anon and authenticated, and grant back only what the app needs. 2) Create a public.entitlements table (user_id, plan, stripe_customer_id, paid_until) that authenticated users can only select, and only their own row. Give clients no insert, update or delete on it. 3) Move every paid/plan check in the app to read from entitlements. 4) Change the Stripe webhook to write entitlements with the Supabase secret key on the server, after verifying the Stripe signature, and never pass the user's access token to that client. 5) On profiles, allow authenticated users to update only display fields (for example display_name and avatar_url). 6) Make sure no secret or service_role key is in any frontend code.
Repeat the console test as a free user: updating is_paid or inserting into entitlements should fail or change nothing. Then do one sandbox checkout and confirm the webhook still unlocks the account. Supabase's RLS guide also shows how to keep this as a database test that asserts allow and deny for each role (source).
Paste your webhook URL. WebhookProof sends 3 harmless test requests and tells you whether Stripe can reach it and whether it rejects unsigned events. If it finds a problem, the $9 fix pack gives you the exact copy-paste fix for your stack (refundable for 14 days).
Run the free checkYes, if an RLS policy lets users update their own row and the authenticated role still has the default table-wide UPDATE grant. RLS restricts rows, not columns, so they can update every column in their row, including is_paid or plan.
Move plan and paid status to a separate table users can only select, or revoke the table-level UPDATE from authenticated and grant UPDATE only on the columns users may edit. Write the paid flag only from your server with the secret key.
Not for the publishable or legacy anon key. Supabase says anyone can read it and it only reaches what RLS allows. The secret or service_role key bypasses RLS and must never be in frontend code.
Stripe webhook not working? Check these 7 things in order · A customer paid but didn't get access. What went wrong? · No signatures found matching the expected signature for payload · Stripe webhook failing with a 307 or 308 redirect
Last reviewed 2026-11-02. WebhookProof is an independent tool, not affiliated with Stripe, Inc.. Quotes are from the linked documentation.