Research / Guides

Troubleshooting guide

File uploads fail or user files are public: fix storage policies

Broken storage policies usually show up in one of two ways. Either every upload fails for every user, or someone who never signed in can open another customer's invoice, photo or export by pasting its URL. We found this pattern in 5 verified builder reports. It usually appears when a builder wants owner-only uploads and writes the storage policies by hand.

5 verified cases in our dataUpdated 2026-09-26How we collect cases

What you'll see

Why it happens

Storage has its own policy layer

In Supabase, file access is controlled by row-level security policies on the storage.objects table. These are separate from the policies on your own tables. With no policies, Storage allows no uploads at all. Uploading needs an INSERT policy, downloading needs SELECT, and deleting needs DELETE. An upsert that overwrites a file needs both SELECT and UPDATE.

The path the client uses doesn't match the path the policy checks

Owner-only policies usually compare the first folder of the object name, via (storage.foldername(name))[1], with the signed-in user's id. If the client uploads to avatar.png, uploads/<id>/avatar.png or <email>/avatar.png, that comparison fails and the upload is rejected. The same mismatch in a SELECT policy makes the user's own uploads unreadable.

The bucket is public

In a public Supabase bucket, anyone with the object URL can download the file, because downloads skip access control. Policies still apply to uploads, deletes, moves and copies, so the bucket can look protected while every file is readable. Random file names make URLs harder to guess, but the URL is still the only barrier.

The read policy doesn't tie the file to its owner

A SELECT policy that checks only bucket_id, or only that the user is authenticated, lets any signed-in user read every object in the bucket. It passes every test you run with one account. Once a second customer signs up, each can read the other's files.

Shared URLs outlive the rules

A Supabase signed URL stays valid until it expires, even if you change Auth keys. Revoking one early needs Supabase support. Firebase's docs note that downloading in code with getBlob() or getBytes(), instead of through a download URL, keeps finer-grained access control under Security Rules. Once a long-lived URL is pasted into an email or a log, your policies no longer decide who can open it.

How to fix it

  1. Make the bucket private

    Switch the bucket to private in the dashboard or with SQL. After that, every download goes through a policy or a signed URL.

    update storage.buckets set public = false where id = 'user-files';
  2. Put the owner's id first in every object path

    Use one path convention everywhere: the user's id, then the file. Build the path from the signed-in session on the client, never from a form field.

    const { data: { user } } = await supabase.auth.getUser()
    const path = `${user.id}/${crypto.randomUUID()}-${file.name}`
    const { error } = await supabase.storage.from('user-files').upload(path, file)
  3. Write insert, select and delete policies that compare the folder to the user

    Each operation needs its own policy on storage.objects, scoped to the bucket and to the authenticated role. If you upload with upsert: true, also add an UPDATE policy with the same check.

    create policy "owner uploads" on storage.objects for insert to authenticated
    with check (bucket_id = 'user-files' and (storage.foldername(name))[1] = (select auth.uid()::text));
    
    create policy "owner reads" on storage.objects for select to authenticated
    using (bucket_id = 'user-files' and (storage.foldername(name))[1] = (select auth.uid()::text));
    
    create policy "owner deletes" on storage.objects for delete to authenticated
    using (bucket_id = 'user-files' and (storage.foldername(name))[1] = (select auth.uid()::text));
  4. Serve files through short-lived signed URLs

    Create a signed URL when the file is needed, and don't store it. The expiry is set in seconds. Keep it short, because revoking a Supabase signed URL before it expires needs Supabase support.

    const { data, error } = await supabase.storage.from('user-files').createSignedUrl(path, 60)
  5. Keep the service role key on the server

    The service role key bypasses every storage policy. Objects it creates have no owner set. If an upload path only works with this key, it is hiding a missing policy.

  6. On Firebase, match the path segment to request.auth.uid

    Cloud Storage Security Rules use the same approach: capture the user id from the path and compare it with the signed-in user. For private files, use getBlob() or getBytes() instead of handing out download URLs.

    match /user/{userId}/{fileName} {
      allow read, write: if request.auth != null && request.auth.uid == userId;
    }
  7. Test as two users and as an anonymous visitor

    A test with one account can't show that owner-only rules work. Run the checks below before every release that touches storage.

Check it's fixed

With Gemmein

Gemmein has no storage policies for you to write. When an AI job (generating an image, video or audio, or transcribing) finishes, its outputs are saved as sealed files on the person who ran it. The app never implements authorization. The Expo and Swift SDKs support sealed files alongside private records. account.delete() erases that person's files along with their sessions, records, subscription and credits. Gemmein is in beta, and it deliberately has no team visibility or per-user record permissions. If you need SQL, migrations and full control, Supabase is the better choice.

Read more on gemmein.com →

Questions

Why does my upload fail with a row-level security error when the user is signed in?

Being signed in isn't enough. An INSERT policy on storage.objects has to pass for that exact object path. The usual causes are a path that doesn't start with the user's id, a missing bucket_id match, or upsert: true without SELECT and UPDATE policies.

Is a public bucket safe if the file names are random?

No. In a public Supabase bucket, anyone with the URL can download the file, and no policy is checked. Random names make the URL hard to guess, but they don't protect it once it has been shared, logged or forwarded. Customer files belong in a private bucket.

Can I revoke a signed URL I already sent?

Not without Supabase support. A Supabase signed URL stays valid until it expires, even if you change Auth keys. Create signed URLs on demand with short expiries, and don't store them.

Does the service role key respect storage policies?

No. It bypasses them, and objects it creates have no owner. Use it only on a server, and don't use it to work around a policy that rejects your client.

What is the Firebase equivalent?

In Cloud Storage Security Rules, match on a path such as /user/{userId}/{fileName} and allow access only if request.auth.uid == userId. Firebase's locked mode denies everything until you write rules like this.

Sources

Related guides

← Back to the full report: what breaks when AI apps go live