Home / RLS / Storage upload: new row violates RLS · Em português
Supabase Storage · Error fix
Storage upload fails: new row violates row-level security policy
Every file is a row in storage.objects, and RLS is on there. With no INSERT policy that matches the path, the upload is refused with 403. Add an INSERT policy for the bucket that ties the first folder to auth.uid(), and upload to <user id>/file. Replacing a file (upsert: true) also needs SELECT and UPDATE policies.
01 What the error is
The Storage API turns the Postgres error into HTTP 403:
response
{ "error": "Unauthorized", "message": "new row violates row-level security policy" }
An upload inserts a row into storage.objects (bucket, path, owner). That table has RLS enabled, and by default no policy lets anyone insert, so every client upload is refused until you add one. Making the bucket public doesn't change this: "public" only affects downloads.
02 The policy: users upload into their own folder
migration.sql
create policy "users upload to their folder" on storage.objects for insert to authenticated with check ( bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid())::text );
storage.foldername(name) returns the folders in the path, so for <user id>/avatar.png the first one is the user id. The upload has to use that path:
upload.ts
const { data: { user } } = await supabase.auth.getUser(); await supabase.storage.from('avatars').upload(`${user.id}/avatar.png`, file);
03 Why it still fails
The path doesn't start with the user id
A file at the bucket root (avatar.png) has no folder, so the check is false. So does another user's folder.
Not signed in
The client calls as anon, so auth.uid() is null and a policy to authenticated doesn't apply.
Replacing a file with upsert: true
Overwriting an existing path is an insert that turns into an update, and Postgres requires matching SELECT and UPDATE policies as well. With only the INSERT policy, the upsert is refused even for a path that doesn't exist yet.
migration.sql (only if you use upsert or read the files back)
create policy "users read their folder" on storage.objects for select to authenticated using (bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid())::text); create policy "users update their folder" on storage.objects for update to authenticated using (bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid())::text);
04 Prove it
A/me.png → okB/x.png, to the root, or to another bucket → refusedDon't "fix" it with with check (true): that lets anyone with your public anon key write anywhere in the bucket.
Is your app leaking right now?
Paste your live URL. The scanner reads your page and its JavaScript for a Supabase service_role key, the one leak that skips every policy in this guide.
Catch this before it ships
airlock-rls is a CI gate that fails your build when a table ships exposed or a policy is permissive: the same class of bug, caught in CI against a database with your migrations applied, before it reaches prod.
Or start from nextjs-supabase-starter : auth + a table with RLS + an isolation test, so you start from a pattern that's already sealed.
Grab the Supabase RLS cheat sheet
The golden rules, the footguns that leak in prod, correct policy snippets, and the isolation test — on one page.
FAQ
I made the bucket public. Why do uploads still fail?
Public only means files can be downloaded without a token. Uploads, updates and deletes still go through the RLS policies on storage.objects.
Can I upload with the service_role key instead?
From a server, yes: it bypasses RLS. Never from the browser, where the key would be exposed. For user uploads, a scoped INSERT policy is the right tool.