Originally published on cenkkurtoglu.com.
Your upload throws new row violates row-level security policy (HTTP 403) — but the exact same upload works with the service_role key. That one detail is the whole diagnosis: files in Supabase Storage are rows in the storage.objects table, RLS applies to that table, and service_role bypasses RLS. So the key is not the fix — your storage.objects policy simply does not match the request you are actually making.
Why service_role "works" and your app key does not
service_role ignores every policy, so a successful upload with it proves nothing about your rules. Your browser or server upload runs as anon or authenticated, and those go through RLS on storage.objects. If no policy grants an INSERT to that role for that bucket, you get the 403.
The four things that actually cause it
-
Role mismatch. You are uploading with the anon key and no signed-in session, so the request is
anon— but your policy targetsauthenticated. Either sign the user in first, or add an explicitanonpolicy if public uploads are intended. -
"Public bucket" is not "public writes." Making a bucket public only affects reads. Uploading still needs an
INSERTpolicy onstorage.objects. -
Missing
bucket_idmatch. A policy that does not scope to your bucket will not apply. Checkbucket_id = 'your-bucket'. -
Per-user folder rule not met. A common pattern requires the first path segment to equal the user id; if you upload to a different prefix, the
WITH CHECKfails.
A working authenticated-upload policy
For "each signed-in user can upload into their own folder in the avatars bucket":
create policy "authenticated users upload to own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
Upload to the path avatars/<user-id>/file.png and it passes. Upload to any other prefix and RLS correctly rejects it.
Confirm which role your upload runs as
select auth.role() as role, auth.uid() as uid;
If it returns anon while your policy expects authenticated, the request is unauthenticated — that is the real problem, not the policy.
Check every policy and role (including storage.objects) in one pass with the Supabase RLS Audit Kit ($29) — 60 checks plus a role-simulation harness that proves isolation. Free read-only demo (runs in ~2s, no Docker): github.com/cekuu35/supabase-rls-leak-demo.
Top comments (0)