new row violates row-level security policy (Supabase)
Your insert or update is blocked by RLS. Add a policy for that exact write, without turning RLS off.
A public bucket lets anyone with a file's URL download it, and a bucket-wide storage.objects policy shares every file with every signed-in user. Find both with two read-only queries, then go private with signed URLs and folder-scoped policies.

A Supabase Storage bucket has one switch, Public, and a set of RLS policies on the storage.objects table. They control different things. Public means anyone with a file's URL can download it, no sign-in needed. The policies control who can list, upload, overwrite and delete. A private file can leak through either one: the bucket is public, or a policy covers the whole bucket instead of each user's own files.
1. Check which buckets are public (Verified: run in the SQL editor or psql as the database owner. It's read-only):
-- Which buckets are public, and which SELECT policies reach them?
select b.id as bucket, b.public,
(select string_agg(p.policyname, ', ')
from pg_policies p
where p.schemaname = 'storage' and p.tablename = 'objects'
and p.cmd in ('SELECT', 'ALL')
and (coalesce(p.qual, '') like '%''' || b.id || '''%'
or coalesce(p.qual, '') not like '%bucket_id%')) as select_policies
from storage.buckets b
order by b.public desc, b.id;
Supabase keeps the flag in a boolean public column on storage.buckets (Supabase: Storage schema design). You can also see it in the dashboard under Storage. Any bucket with public = t that holds user uploads (IDs, invoices, receipts, private photos) needs step 3. select_policies lists the select policies that name the bucket or don't filter by bucket at all. On a public bucket, those let people list the files too (see below). In our test, the query flagged our public documents bucket and its bucket-wide read policy, and after the fix showed public = f with only the folder-scoped policy (Verified). It matches the bucket id as text inside the policy, so treat it as a pointer and read the policies themselves in step 2.
2. Find broad policies on storage.objects (Verified, read-only):
-- Permissive storage.objects policies not limited to the signed-in user
with p as (
select policyname, cmd, roles, qual, with_check,
coalesce(qual, '') || ' ' || coalesce(with_check, '') as expr
from pg_policies
where schemaname = 'storage' and tablename = 'objects'
and permissive = 'PERMISSIVE'
)
select policyname, cmd, roles, qual as using_expr, with_check as with_check_expr,
case
when lower(regexp_replace(coalesce(qual, ''), '[\s()]', '', 'g')) in ('true', '1=1')
or lower(regexp_replace(coalesce(with_check, ''), '[\s()]', '', 'g')) in ('true', '1=1')
then 'always true: every file in every bucket'
when expr not like '%bucket_id%'
then 'no bucket_id: applies to every bucket'
when expr !~ '(auth\.uid|auth\.jwt|owner)'
then 'bucket-wide: not limited to the signed-in user'
end as concern
from p
where lower(regexp_replace(coalesce(qual, ''), '[\s()]', '', 'g')) in ('true', '1=1')
or lower(regexp_replace(coalesce(with_check, ''), '[\s()]', '', 'g')) in ('true', '1=1')
or expr not like '%bucket_id%'
or expr !~ '(auth\.uid|auth\.jwt|owner)'
order by cmd, policyname;
How to read it: using_expr decides which existing files a role can list, download, overwrite or delete. with_check_expr decides where it can upload (PostgreSQL: pg_policies). In our test it flagged all four broad policies we planted: an update ... using (true) as "always true", and a select, an insert and a delete limited only by bucket_id = 'documents' as "bucket-wide". After the fix it returned 0 rows (Verified). A bucket-wide policy is fine when every signed-in user really should see every file in that bucket (shared team assets, for example). For per-user files, go to step 3. The query only looks for auth.uid, auth.jwt or owner in the rule, so a policy that mentions them can still be wrong. Read each one.
3. Make the bucket private and scope the policies to each user's folder (Verified). This example is for a bucket called documents where each file is stored as <user id>/<file name>. Use your own bucket and policy names from steps 1 and 2:
-- 1. Make the bucket private (or use the dashboard / updateBucket)
update storage.buckets set public = false where id = 'documents';
-- 2. Drop the broad policies (names come from step 2)
drop policy if exists "docs read" on storage.objects;
drop policy if exists "docs upload" on storage.objects;
drop policy if exists "docs update" on storage.objects;
drop policy if exists "docs delete" on storage.objects;
-- 3. Each user only touches files under a folder named with their user id
drop policy if exists "documents: read own folder" on storage.objects;
drop policy if exists "documents: upload to own folder" on storage.objects;
drop policy if exists "documents: update own folder" on storage.objects;
drop policy if exists "documents: delete own folder" on storage.objects;
create policy "documents: read own folder" on storage.objects
for select to authenticated
using ( bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text) );
create policy "documents: upload to own folder" on storage.objects
for insert to authenticated
with check ( bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text) );
create policy "documents: update own folder" on storage.objects
for update to authenticated
using ( bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text) )
with check ( bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text) );
create policy "documents: delete own folder" on storage.objects
for delete to authenticated
using ( bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text) );
The folder pattern comes from Supabase's access control guide, which restricts uploads with bucket_id = 'my_bucket_id' and (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub') (Supabase: Storage access control). storage.foldername() returns the folders in a file's path (Supabase: Storage helper functions). The same guide says upserts need select and update policies as well as insert. That's why all four are here. Your upload code has to put files under the user's id, for example upload(`${user.id}/receipt.pdf`, file). We used folder paths rather than the owner_id column because files uploaded with the service key had an empty owner_id in our test (Verified). The SQL re-ran cleanly a second time (Verified).
The update storage.buckets line is the fix Supabase's linter docs give for a public bucket (Supabase splinter: 0025 public bucket allows listing). In our test the public URL stopped working as soon as it ran, with no restart (Verified, local storage server only). Supabase also says to treat the storage tables as read-only and change things through the API (Supabase: Storage schema design). If you'd rather follow that, flip the bucket to private in the dashboard, or call supabase.storage.updateBucket('documents', { public: false }) from a server with the secret key (Supabase: updateBucket). (Docs-only: we ran the SQL, not the dashboard or updateBucket.)
4. Serve private files with signed URLs (Verified). Where the app needs a link (an <img> tag, a download button), create a short-lived signed URL for the signed-in user instead of a public URL:
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrl(`${user.id}/receipt.pdf`, 60) // seconds
Supabase says private files can be read with the user's own session, or with a URL from createSignedUrl "that can be accessed for a limited time" (Supabase: Storage buckets, Supabase: createSignedUrl). In our test, A got a signed URL for A's own file, it worked with no sign-in (200), and stopped working after it expired ("exp" claim error, 400). Swapping a different file name into the signed URL failed (400). With the fixed policies, B couldn't get a signed URL for A's file (Verified). Anyone holding a signed URL can use it until it expires, so keep the expiry short.
5. Re-run Security Advisor. Supabase's database linter has a lint for public buckets that also allow listing, 0025_public_bucket_allows_listing (Supabase: Database Advisors). (Docs-only: we ran the queries above, not the hosted Advisor.)
https://[project_id].supabase.co/storage/v1/object/public/[bucket]/[file], and getPublicUrl() generates it for you (Supabase: Serving downloads). In our test, with no select policy at all, a signed-out request still downloaded the file from its public URL (200), and B still downloaded A's file (Verified).storage.objects policies.[] for signed out and for B, even though the files downloaded by URL (Verified). After we added a select policy with only bucket_id = 'documents', signed out and B could list every file name in A's folder (Verified). Supabase's linter docs say public buckets "are already readable by URL. They do not need a SELECT policy on storage.objects", and a broad one lets clients list every file (Supabase splinter: 0025).bucket_id lets every signed-in user read (or write) every file in that bucket, private or not. In our test, B overwrote and deleted A's file through such policies (Verified).The Public switch is the quick way to make images show up: no signed URLs, no policies, every getPublicUrl() link just works. That's the right call for avatars, logos and product photos, and Supabase lists user profile pictures, public media and blog post content as public-bucket use cases (Supabase: Storage buckets). It's the wrong call once the same bucket starts holding receipts or ID scans.
The policies drift the other way. Supabase's access control guide starts from an insert policy with with check ( true ) and then narrows it to a bucket and a folder (Supabase: Storage access control). Stop halfway, at bucket_id = '...', and every signed-in user shares the whole bucket. Policies on storage.objects are ordinary Postgres RLS policies, so permissive ones are OR'd together (PostgreSQL: CREATE POLICY). One broad policy opens the bucket even when a folder-scoped one sits next to it, the same trap as using (true) on a table.
service_role key in the browser to get past a storage error. "Service keys entirely bypass RLS policies, granting you unrestricted access to all Storage APIs" (Supabase: Storage access control).storage.objects to remove files. Supabase says that removes the metadata but not the file itself; use the Storage API (Supabase: Storage schema design).I use Supabase Storage. Some buckets hold private user files.
Run these read-only queries and show me the results:
select id, public from storage.buckets order by public desc, id;
select policyname, cmd, roles, qual, with_check
from pg_policies where schemaname = 'storage' and tablename = 'objects';
For each public bucket, tell me whether it holds private files.
For private files, propose: making the bucket private, signed URLs (createSignedUrl with a
short expiry) where the app shows those files, and storage.objects policies that check
bucket_id and (storage.foldername(name))[1] = (select auth.uid()::text) for select, insert,
update and delete. Update my upload code to store files under the user's id.
Do not use a secret or service_role key in client code. Do not delete rows from storage.objects.
Show the SQL and code first and wait for my OK before applying anything.
public = t, with no select policy. Step 2 should list only policies you meant to be bucket-wide./storage/v1/object/public/<bucket>/<path>) and open it in a private window. Expect an error, not the file.42501 "permission denied" is a different problem: a missing grant. See 42501 permission denied.)Our Supabase RLS guide · RLS is on, but my table still leaks · 42501 permission denied · new row violates RLS · Supabase self-check
Did step 1 turn up a public bucket you didn't expect? Tell us what kind of files it held (avatars, receipts, IDs). We'll add the patterns we see most.
This guide is educational and based on the vendor's public docs as of the review date. Platforms change: check the linked sources. Not a security audit. Disclaimer.
From BuiltSafely · builtsafely.com · https://builtsafely.com/fixes/supabase-storage-bucket-public/
The BuiltSafely weekly
Platform changes that affect the security of AI-built apps, checked against official docs. Free. Unsubscribe any time.