Storage 403: new row violates row-level security policy on upload

Why a Storage upload fails with 403 "new row violates row-level security policy", and the storage.objects policies you need for uploads, upserts and per-user folders.

Every Storage upload inserts a row into storage.objects, and that table has Row Level Security. If no policy allows the insert for this user, this bucket and this path, Storage refuses the upload with 403.

What you see

From supabase-js:

const { error } = await supabase.storage.from('receipts').upload('2026/march.pdf', file)
console.log(error.message) // "new row violates row-level security policy"

The HTTP response is a 403 with:

{"statusCode":"403","error":"Unauthorized","message":"new row violates row-level security policy"}

Why it happens

  1. There's no insert policy on storage.objects for this bucket. A new bucket has no policies, so only service_role can write to it.
  2. The policy checks the path and the path doesn't match. A policy that expects <user id>/file.pdf rejects 2026/march.pdf.
  3. The user isn't signed in, so the request runs as anon and a policy written to authenticated doesn't apply. This often happens on the server when the client wasn't given the user's cookies.
  4. You're using upsert: true. Overwriting needs select and update permission as well as insert. Without them, uploading a new file works but replacing one fails.
  5. The policy names the wrong bucket: bucket_id holds the bucket's id, which is usually its name, and it's case-sensitive.

How to fix it

  1. Upload into a folder named after the user:

    const { data: { user } } = await supabase.auth.getUser()
    const { error } = await supabase.storage
      .from('receipts')
      .upload(`${user.id}/2026-03.pdf`, file, { contentType: 'application/pdf' })
    
  2. Allow each user to insert into their own folder:

    create policy "Users upload own receipts"
    on storage.objects for insert to authenticated
    with check (
      bucket_id = 'receipts'
      and (storage.foldername(name))[1] = (select auth.uid())::text
    );
    
  3. If you upload with upsert: true, add select and update too:

    create policy "Users read own receipts"
    on storage.objects for select to authenticated
    using (
      bucket_id = 'receipts'
      and (storage.foldername(name))[1] = (select auth.uid())::text
    );
    
    create policy "Users replace own receipts"
    on storage.objects for update to authenticated
    using (
      bucket_id = 'receipts'
      and (storage.foldername(name))[1] = (select auth.uid())::text
    );
    
  4. Check the policies that exist:

    select policyname, cmd, roles, qual, with_check
    from pg_policies
    where schemaname = 'storage' and tablename = 'objects';
    
  5. Uploads that should happen without a user (an import job, a webhook) belong on the server with the service_role key, which bypasses these policies. Never put that key in the browser.

On SupaHost

Storage works as it does in open-source Supabase, so the same policies apply. Create them in Studio's SQL editor or the Storage policies screen (open Studio with the Studio button in the project header; owner only), or with a migration through MCP.

A file above 50 MB fails with 413, not 403. See upload file size limits.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us