DEV Community

Cover image for Supabase bucket RLS policy for table objects fix
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

Supabase bucket RLS policy for table objects fix

The exact error looks like this:

new row violates row-level security policy for table "objects"
Enter fullscreen mode Exit fullscreen mode

The root cause is usually one of three things:

  1. you have no INSERT policy on storage.objects
  2. you are using upsert: true, which needs more than just INSERT
  3. you passed the bucket and file path in the wrong places

That is why this error shows up even when the user is already signed in.

The first thing to fix: bucket name vs file path

This is the broken shape from the real Stack Overflow thread:

// Wrong
await supabase.storage
  .from('/public/avatars')
  .upload(`${email}.png`, file, { upsert: true })
Enter fullscreen mode Exit fullscreen mode

Supabase's own upload docs show the correct split:

const { data, error } = await supabase.storage
  .from('avatars')
  .upload('public/avatar1.png', file, {
    cacheControl: '3600',
    upsert: false,
  })

if (error) throw error
Enter fullscreen mode Exit fullscreen mode

from() takes the bucket. upload() takes the path inside that bucket.

The minimal policy for a plain upload

Supabase documents that the only policy required for uploading objects is an INSERT policy on storage.objects.

A minimal bucket-scoped policy looks like this:

create policy "Allow authenticated uploads to avatars"
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'avatars'
);
Enter fullscreen mode Exit fullscreen mode

If your uploads should go only into a specific folder, add a folder constraint too:

create policy "Allow authenticated uploads to avatars/private"
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'avatars' and
  (storage.foldername(name))[1] = 'private'
);
Enter fullscreen mode Exit fullscreen mode

The upsert: true trap

This is the part many articles miss. Supabase's Storage access-control docs explicitly say that overwriting files with upsert needs SELECT and UPDATE permissions in addition to the upload policy.

So this code:

await supabase.storage
  .from('avatars')
  .upload(`private/${userId}/avatar.png`, file, {
    upsert: true,
  })
Enter fullscreen mode Exit fullscreen mode

needs more than just for insert.

If you do not actually need overwrite behavior, make the fix smaller:

await supabase.storage
  .from('avatars')
  .upload(`private/${userId}/avatar.png`, file, {
    upsert: false,
  })
Enter fullscreen mode Exit fullscreen mode

That alone resolves a surprising number of production bugs.

If you really do need overwrite behavior

Keep the insert policy, then add read/update policies that match the same object scope.

create policy "Allow authenticated reads on avatars/private"
on storage.objects
for select
to authenticated
using (
  bucket_id = 'avatars' and
  (storage.foldername(name))[1] = 'private'
);

create policy "Allow authenticated updates on avatars/private"
on storage.objects
for update
to authenticated
using (
  bucket_id = 'avatars' and
  (storage.foldername(name))[1] = 'private'
)
with check (
  bucket_id = 'avatars' and
  (storage.foldername(name))[1] = 'private'
);
Enter fullscreen mode Exit fullscreen mode

A safe client upload example

const filePath = `private/${userId}/avatar.png`

const { data, error } = await supabase.storage
  .from('avatars')
  .upload(filePath, file, {
    cacheControl: '3600',
    upsert: false,
  })

if (error) {
  throw error
}
Enter fullscreen mode Exit fullscreen mode

This is the right place to start. Only add overwrite behavior once the basic insert path works.

When the service key changes everything

Supabase's Storage docs also note that service keys bypass Storage RLS entirely. That can be useful for trusted server-side jobs, but it is not a fix for a browser upload bug. If a client upload needs the service key to work, the policy is still wrong.

The better debugging order is:

  1. fix the bucket/path split
  2. test with upsert: false
  3. add the exact INSERT policy
  4. only then add SELECT and UPDATE if overwrite is required

Scoping uploads to the signed-in user

The folder constraint above used a literal ('private'). To restrict every user to their own folder, match the first path segment against the user id. This is the pattern Supabase's own docs recommend, and it survives multi-tenant scale:

create policy "Users upload to their own folder"
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'avatars' and
  (storage.foldername(name))[1] = (select auth.uid()::text)
);
Enter fullscreen mode Exit fullscreen mode

Upload to a path whose first segment is the user id, and the check passes:

const filePath = `${user.id}/avatar.png`

await supabase.storage
  .from('avatars')
  .upload(filePath, file, { upsert: false })
Enter fullscreen mode Exit fullscreen mode

auth.uid() returns a UUID; the cast to text matters because storage.foldername(name) returns text segments. Wrapping it in (select ...) lets Postgres evaluate it once per statement instead of once per row.

Which role and which FOR clause

Two details decide whether your policy ever fires:

  • Role (TO clause). A signed-in user's request runs as authenticated, not anon. A policy scoped to anon will never apply to a logged-in upload, and vice-versa. The service_role key bypasses RLS entirely — useful for trusted server jobs, never a fix for a browser upload.
  • Operation (FOR clause). for insert covers only the upload. If the same per-user predicate should also gate reads, updates, and deletes, use for all with both using (read/delete predicate) and with check (write predicate):
create policy "Full access to own files"
on storage.objects
for all
to authenticated
using ((storage.foldername(name))[1] = (select auth.uid()::text))
with check ((storage.foldername(name))[1] = (select auth.uid()::text));
Enter fullscreen mode Exit fullscreen mode

owner vs owner_id: don't gate on the wrong column

A common dead end is writing the policy against an ownership column instead of the path. If you go that route, use the right column: modern Supabase stores the uploader in owner_id (text), and the older owner (uuid) column is deprecated. A policy like with check (owner_id = auth.uid()) also needs a cast (owner_id is text, auth.uid() is uuid) — with check (owner_id = (select auth.uid()::text)). In practice the folder-path pattern above is simpler and less error-prone than relying on the auto-set owner column, so prefer it unless you have a reason not to.

For the surrounding security pieces:

References


Originally published at https://www.iloveblogs.blog

Top comments (0)