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.
The exact error looks like this:
new row violates row-level security policy for table "objects"The root cause is usually one of three things:
- you have no
INSERTpolicy onstorage.objects - you are using
upsert: true, which needs more than justINSERT - 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 })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 errorfrom() 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'
);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'
);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,
})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,
})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'
);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
}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:
- fix the bucket/path split
- test with
upsert: false - add the exact
INSERTpolicy - only then add
SELECTandUPDATEif 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)
);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 })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 (
TOclause). A signed-in user's request runs asauthenticated, notanon. A policy scopedto anonwill never apply to a logged-in upload, and vice-versa. Theservice_rolekey bypasses RLS entirely — useful for trusted server jobs, never a fix for a browser upload. - Operation (
FORclause).for insertcovers only the upload. If the same per-user predicate should also gate reads, updates, and deletes, usefor allwith bothusing(read/delete predicate) andwith 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));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:
- Supabase Storage: Guide to File Uploads and Management
- File Storage and Media Handling with Next.js and Supabase
- Why Your Supabase RLS Policies Are Silently Failing (And How to Debug Them)
- Next.js + Supabase Security: RLS, Secrets, and the Mistakes That Leak Data
References#
- Stack Overflow: Supabase bucket - new row violates row-level security policy for table "objects"
- Supabase docs: Storage access control
- Supabase docs: Upload a file
The upload works, then the file will not load: public vs private buckets#
Once the RLS policies above are in place and the upload itself succeeds, a second, unrelated failure often shows up right after: the file uploads cleanly, but the URL you show the user returns a not-found or permission error in the browser. This is not another RLS bug — it is a mismatch between the bucket's visibility setting and the method used to build the URL.
getPublicUrl() does not check anything server-side. It just builds and returns a
URL string by concatenating the project's storage host with the bucket and path —
it never verifies the bucket is actually public, and it never fails even for a
bucket that is fully private. If the bucket was created (or later switched) to
private, the URL getPublicUrl() hands back looks correct but the underlying
object request is rejected, because a private bucket enforces RLS on reads the same
way it enforces RLS on the insert this page already covers.
The fix for a private bucket is a different client call entirely — createSignedUrl(),
which asks the server to mint a time-limited, signed token for that specific object:
const { data, error } = await supabase.storage
.from('avatars')
.createSignedUrl('private/user-id/avatar.png', 60 * 60) // expires in 1 hour
if (error) throw error
// data.signedUrl is safe to use directly, no separate SELECT policy needed
// for the signing call itselfcreateSignedUrl() still requires the caller to have read access to sign the URL,
but the resulting signed link itself carries its own short-lived authorization, so
it works even when the bucket has no public-read policy at all. Reserve
getPublicUrl() for buckets you deliberately created as public (avatars, blog
cover images, anything meant to be reachable without a session) — the moment a
bucket needs the folder-scoped, per-user policies covered earlier in this page, it
should be private, and every read path needs createSignedUrl() instead. The
full walkthrough of the upload → serve pipeline for both bucket types, including
where to set the public/private flag when creating a bucket, is in
Supabase Storage File Uploads in Next.js: 2026 Guide.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Continue Reading
Test Supabase RLS Policies Before You Ship
Every RLS leak I have seen shipped the same way: the policy was tested in a context that does not enforce it. The Supabase SQL editor runs as a privileged role, the service key carries `BYPASSRLS`, and the table owner is exempt from its own policies unless you say otherwise. Three green checks, zero enforcement. This is the procedure that actually tests a policy — plus the two Postgres flags that decide whether your test means anything.
Does Supabase service_role Bypass RLS? Yes — Use It Safely
The Supabase service_role key bypasses every RLS policy in your database. That is exactly what you want for admin jobs, migrations, and cron tasks — and exactly what an attacker wants if it ever leaks to the client bundle. Here is how to set up the admin client in Next.js, where it is safe to use, and the three mistakes that put a root credential in your JS bundle.
Supabase 42501: permission denied for schema auth
A view over `auth.users`, a policy that joins it, or a client query against it all fail the same way: `ERROR: 42501: permission denied for schema auth`. It is not a missing GRANT you forgot — Supabase keeps the `auth` schema out of reach of the API roles deliberately, and granting your way in is the one fix you should not apply. The supported route is a security definer function.


