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. It usually appears when a builder wants owner-only uploads and writes the storage policies by hand.

12 min read Updated 10 Nov 2026Last reviewed 10 Nov 2026How we collect cases

What you'll see

  • Every upload fails for every user with a 403 or "new row violates row-level security policy", even though the user is signed in.
  • Uploads work in the dashboard or from a server script but fail from the app.
  • The first upload works, but replacing a file (upsert) fails.
  • A file URL opens in a private browser window with nobody signed in.
  • Changing the user id or folder name in a file URL opens another customer's file.
  • A link shared once, for example in a support email, still opens the file weeks later.

Why it happens

Cause 1

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.

Cause 2

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.

Cause 3

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.

Cause 4

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.

Cause 5

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, not 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 individual signed URLs can't be revoked; to invalidate one early, delete, move, or rename the underlying object.

    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

  • Signed in as user A, an upload to A's own folder succeeds and an upload to user B's folder is rejected.
  • Signed in as user B, downloading, listing or creating a signed URL for a path in A's folder returns an error or an empty list.
  • In a private window with nobody signed in, the old public URL for one of A's files no longer returns the file.
  • A signed URL stops working once its expiry has passed.

Fix it with Gemmein

Customer uploads are built in: they go in through a collection's upload(), upload permission follows that collection's write rule, and a record stores a reference that grants nothing on its own. To show or download a file you call g.files.link(), which is refused for a person who may not see the file; outside collections anyone can read, it hands a signed link that works for 2 minutes, 5 minutes with { intent: "download" }.

  1. Pick the collection's rule by who the file belongs to

    For files a customer keeps for themselves, private fits: each signed-in user sees and edits only their own records, and upload permission follows the collection's write rule. When the business hands a client a document, the owner does it from the dashboard, attaching it on the client's record there.

    // gemmein/collections/uploads.json
    { "rule": "private", "means": "each customer's own uploaded photos and documents" }
  2. Upload through the collection and store the reference

    Call upload() on the collection rather than wiring up your own storage bucket, and store file.ref in a record field, not a URL; on every rule, a top-level ref must name a real, confirmed file the writer may read, or the write is refused with 400 unknown_file. Images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB) are accepted at 25 MB per file; office files are not, so zip an office file.

    const file = await g.collection("uploads").upload(blob, { name });
    await g.collection("uploads").create({ title, photo: file.ref });
  3. Name the other reader when a message carries a file

    On a direct collection, a file can name one other reader with upload(blob, { for: userId }), as the direct message's attachment. That recipient's files.link works and everyone else's still refuses; without for, only the uploader can link it.

    const file = await g.collection("messages").upload(blob, { for: userId });
  4. Ask for a link each time you render

    Call g.files.link() with the stored ref, which sits under the record's .data, when you show the file, and don't store what it returns; link documents with { intent: "download" }, as they always download. If the person may not see the file, link() is refused: 404 when it isn't theirs, 403 entitlement_required when a plan or product is missing.

    const { url } = await g.files.link(record.data.photo);

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

Last reviewed 10 Nov 2026. What changed: 1 step(s) corrected to match current docs. Checked against: supabase.com, supabase.com, supabase.com, supabase.com, supabase.com, firebase.google.com, firebase.google.com. How we check this

← Back to the full report