Supabase bucket RLS policy for table objects fix
Technology

Supabase bucket RLS policy for table objects fix

This Storage error almost never means Supabase is broken. It usually means your upload path, your RLS policy, or your use of `upsert` does not match how Storage actually authorizes writes.

2026-06-06
7 min read
Supabase bucket RLS policy for table objects fix

Supabase bucket RLS policy for table objects fix#

The exact error looks like this:

text
new row violates row-level security policy for table "objects"

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:

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

Supabase's own upload docs show the correct split:

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

if (error) throw error

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:

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

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

sql
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'
);

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:

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

needs more than just for insert.

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

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

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.

sql
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'
);

A safe client upload example#

ts
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
}

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:

sql
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)
);

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

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

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

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):
sql
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));

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#

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.