Fix: new row violates row-level security policy on Supabase Storage upload
TL;DR: new row violates row-level security policy for table "objects" on a Supabase Storage upload means RLS is doing its job. A file upload is an INSERT into the storage.objects table, that table has RLS enabled by Supabase, and there is no INSERT policy that lets your row through — so Postgres default-denies the write (SQLSTATE 42501). Fix it by adding an INSERT policy on storage.objects scoped by bucket_id and, for the standard per-user-folder pattern, a folder check: (storage.foldername(name))[1] = (select auth.uid()::text).
Nothing is broken here. Postgres received a write into a real table, evaluated your row-level security policies, found none that permitted the row, and aborted the statement. The whole job is to add the policy that was missing — not to disable RLS, and not to make the bucket public.
Why does uploading a file throw "new row violates row-level security policy for table objects"?
Because Supabase Storage is not a magic API — it is a normal Postgres table with RLS turned on. Every object's metadata (its id, bucket_id, name — the object path — owner_id, created_at) lives as a row in storage.objects. When you call supabase.storage.from('avatars').upload(...), the storage service runs an INSERT into storage.objects, and that write is filtered through the exact same RLS machinery as any table in your public schema.
storage.objects ships with RLS enabled. That means the default is deny: with no permissive INSERT policy in place, no row can be written. The error string reports the relation unqualified as "objects" (not storage.objects), which is why the message looks table-agnostic even though the cause is storage-specific.
This is distinct from the same 42501 error on a regular public-schema table like todos, and it is the opposite failure mode from a public bucket leaking reads. Here the failing table is specifically storage.objects and the trigger is an upload.
How do I fix the Supabase Storage upload RLS error?
Add an INSERT policy on storage.objects. You can't ALTER TABLE storage.objects — Supabase owns the storage schema — but Supabase grants you CREATE POLICY on it, via the SQL editor or the dashboard under Storage > Policies (which even ships a ready-made template for exactly this per-user-folder pattern).
-- storage.objects already has RLS ENABLED by Supabase — you do not (and cannot)
-- run ALTER TABLE on it. An upload is an INSERT, so it needs a passing WITH CHECK.
-- 1) The missing piece: an INSERT policy (this silences the 42501 error).
create policy "Users can 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)
);
-- 2) Matching SELECT / UPDATE / DELETE policies (add the ones you use).
-- A PRIVATE bucket needs a SELECT policy to download; a PUBLIC bucket's
-- downloads bypass RLS — but either way you STILL need INSERT to upload.
create policy "Users can read their own files"
on storage.objects for select to authenticated
using ( bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid()::text) );
create policy "Users can update their own files"
on storage.objects for update to authenticated
using ( bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid()::text) )
with check ( bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid()::text) );
create policy "Users can delete their own files"
on storage.objects for delete to authenticated
using ( bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid()::text) );
Two predicates carry the policy. bucket_id = 'avatars' pins the rule to one bucket, because storage.objects holds rows for every bucket in your project and you don't want an avatars policy touching unrelated files. (storage.foldername(name))[1] = (select auth.uid()::text) enforces the per-user folder: storage.foldername(name) is a built-in Supabase helper that splits the object path into an array of folder segments, and [1] is the first one. So a user may only write under a top-level folder named after their own UID — e.g. <uid>/avatar.png.
Note the deliberate choices. for insert uses WITH CHECK, never USING — an INSERT has no pre-existing row, so USING is meaningless and Postgres rejects it on a for-insert policy. The ::text cast is required because auth.uid() returns uuid and the folder segment is text. And (select auth.uid()::text) is the initPlan-cached form Supabase recommends so the auth call is evaluated once per statement, not once per row.
Why does the client path matter?
Because the folder check reads the path you upload to. If your object path has no folder segment, (storage.foldername(name))[1] returns NULL, the predicate is never TRUE, and you get 42501 again. The client must prefix the path with the user's UID:
const { data: { user } } = await supabase.auth.getUser();
await supabase.storage.from('avatars').upload(`${user.id}/avatar.png`, file);
// NOT .upload('avatar.png', file) <-- no folder segment -> [1] is NULL -> denied
Two traps live in that one line. Postgres arrays are 1-indexed, so developers coming from JavaScript who write [0] get NULL on every upload — the first folder is [1]. And uploading to the bucket root (no folder) fails for the same reason.
Why does the upload work from my server but fail in the browser?
Because your server code is almost certainly using the service_role key, which bypasses RLS entirely. Server-side uploads with SUPABASE_SERVICE_ROLE_KEY skip every storage.objects policy; the browser using the anon key does not. That's a diagnosis clue that you're missing a client-facing policy — not a reason to move uploads server-side with the service key. Never ship that key to the client (here's why that's critical).
The related cause is authentication. If the client uploads with only the anon key and no signed-in session, auth.uid() is NULL, (storage.foldername(name))[1] = NULL::text is never TRUE, and a to-authenticated policy denies it. Either sign the user in so a real JWT reaches the database, or — for deliberately anonymous uploads — write a policy to the anon role with a predicate that doesn't depend on auth.uid(). This is the same root cause behind a lot of RLS grief; see why auth.uid() returns NULL.
Does making the bucket public fix it?
No. A bucket's public flag only affects reads — it makes object GETs bypass RLS for downloads. It does not relax RLS on writes. Uploading to a public bucket still runs an INSERT into storage.objects, so a missing INSERT policy throws 42501 even on a public bucket. Making a bucket public to fix an upload error is a dead end; add the policy instead.
FAQ
Why is the error about a table called "objects" when I never made that table?
Supabase Storage stores every file's metadata as a row in the storage.objects table. Postgres reports the relation name unqualified, so storage.objects shows up as just "objects" in the message. The upload is an INSERT into that table.
Do I use USING or WITH CHECK for a storage upload policy?
WITH CHECK only. An upload is an INSERT, which has no existing row, so USING is meaningless and Postgres rejects it on a for-insert policy. WITH CHECK validates the bucket_id and name of the row being written.
Why does my second upload to the same path fail with 42501?
{ upsert: true } turns an upload of an existing object into an UPDATE on storage.objects, which needs an UPDATE policy (both USING and WITH CHECK). A bucket with only an INSERT policy passes the first time and fails on the overwrite.
It works in the SQL editor — why not from my app?
The SQL editor runs as the postgres role, which bypasses RLS, so a statement that "passes" there can still fail from the client. Test as a real signed-in user through the SDK, or impersonate the authenticated role with set local role and forged request.jwt.claims — see how to test Supabase RLS.
Can I just disable RLS on storage.objects to make this go away? No — you can't, and you shouldn't. Supabase owns the storage schema and keeps RLS on. Disabling it (even if you could) would make every file in the project world-writable. Add the correct INSERT policy.
Where GuardLayer fits — and where it honestly doesn't
Be clear-eyed: this 42501 is a runtime error, and it's RLS working correctly — a safe, fail-closed denial of a write. GuardLayer is a static scanner that reads your code and SQL migrations. It never observes an upload at runtime, so it cannot tell that you forgot an INSERT policy on storage.objects, and it does not claim to detect or "fix" this error. In fact its supabase/rls-missing-on-table rule deliberately skips the storage schema (storage is on the internal-schemas skip-list; the scan inspects only public-schema tables), so it neither false-positives nor helps here.
What GuardLayer genuinely catches is the fail-open inverse — the storage mistakes that don't throw an error and instead silently over-expose files. Its supabase/public-storage-bucket rule flags a bucket created or updated as public: true in JS (createBucket/updateBucket) or in SQL, the opposite storage mistake where reads leak — see the public-bucket leak post. And because supabase/policy-using-true and supabase/policy-no-user-scope match any CREATE POLICY regardless of schema, if someone "fixes" this 42501 by slapping with check (true) on storage.objects, or writes a storage policy whose predicate never references auth.uid(), GuardLayer flags it as over-permissive — the USING (true) trap.
That's the real danger: not the loud, safe 42501 you can fix in five minutes, but the quiet over-correction where someone makes the error "go away" by stripping out the very scoping that protects users' files.
Catch this before it ships — free
GuardLayer scans every push for this and 24 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.