The policy looks right. The error says permission denied. Postgres never got as far as your policy. Here is the order it checks things in and how to fix each layer.
A request to Supabase fails with "permission denied for table", or error code 42501. You check and there is a row level security policy on the table. It looks correct.
It often shows up on insert with return=representation, which supabase-js uses when you chain .select(). Or a brand new table works for you as owner, while the app sees nothing. Or background jobs suddenly die with this error after a security lockdown that looked unrelated.
Postgres checks two layers, in order. First the GRANT: is this role allowed to touch this table at all? Then RLS: which rows may it touch? If the GRANT is missing, Postgres stops at layer one. Your policy never runs.
Three common ways to end up there:
Step 1. Make sure you have the right key and project. This one curl tells you a lot:
curl -s "https://PROJECT.supabase.co/rest/v1/my_table?select=id&limit=1" \
-H "apikey: $K" -H "Authorization: Bearer $K"
"permission denied for table" means right project, and the permissions layer is doing its job. "Invalid API key" means the key belongs to a different project. Credential files grouped by project often make a search return the nearest key, not the right one.
Step 2. Grant the role what it needs. Use the role your requests actually run as, anon or authenticated:
grant select, insert, update on public.my_table to authenticated;
If you insert and read back the row, SELECT must be in that list.
Step 3. Make sure matching policies exist. A grant without a policy still returns nothing under RLS:
alter table public.my_table enable row level security;
create policy "read own rows" on public.my_table
for select to authenticated using (user_id = auth.uid());
create policy "insert own rows" on public.my_table
for insert to authenticated with check (user_id = auth.uid());
Step 4. Test with the real key. Test as the anon or signed in role your app uses. The service key bypasses RLS, so it proves nothing about what users see.
Step 5. Fix server functions. Server side code should read the service key from the environment, never a hardcoded publishable key. Before you tighten RLS or revoke anon grants, search your functions for the publishable key prefix.
Step 6. Guard every migration. End migrations with a check that fails loudly if policy coverage is short:
do $$
begin
if (select count(*) from pg_policies
where schemaname = 'public' and tablename = 'my_table') < 2 then
raise exception 'policy coverage short on my_table';
end if;
end $$;
Apply migrations before you deploy code that reads them. Code deployed first fails on the missing column.
Every entry follows the same pattern: the symptom you see, the real cause, the fix that worked, and the rule that stops it coming back. App Store, Google Play, Capacitor, Netlify, Supabase, Stripe, n8n and more.
Get Field NotesBuilding a lot? The Builder Bundle has Field Notes plus the launch playbook, the prompt book and the Claude Code skills kit. See the bundle.
The bucket exists.
You added one private key and now nothing deploys.
Google will not sign anyone in inside an embedded webview.
What broke and how we fixed it, across app stores, hosting, databases and payments.