Launch-readiness teardown

Your Table RLS Doesn't Protect Your Files

You ran the two-user test. Your database rejects a direct request for another account's row. Then a user uploads an invoice, an ID photo, a private export — and it's served to anyone with the link.

Supabase Storage is a separate system from your Postgres tables. The Row Level Security you wrote for documents does not apply to files. Storage has its own access model — bucket visibility plus policies on storage.objects — and the convenient setup quietly serves private uploads to the world.

There are two ways an AI-built app gets here, and both look fine in the demo.

A public bucket used for private files. Buckets are private by default, but a public bucket is the fastest way to make <img src> "just work" without signed URLs — so the generator (or a stuck developer) flips it on. Now every object in that bucket is world-readable by URL. Fine for avatars. Not fine for the invoice you also uploaded there.

A private bucket with a policy that doesn't scope to the owner. A private bucket is protected by policies on storage.objects. A policy like "authenticated users can select" reads as safe and lets any signed-in user read every file. The policy has to tie the object to the user who owns it.

The useful launch question is concrete:

If I take the URL of a private file and open it signed out — or fetch it as a different user — do I get the file?

The invariant, in Storage terms

A file in a private bucket must be reachable only by someone the policy allows. The owner check usually lives in the object's path: files are stored under a prefix like user_id/filename, and the policy compares that prefix to auth.uid().

-- storage.objects policy: a user may read only files under their own folder
create policy "own files only"
on storage.objects for select
to authenticated
using (
  bucket_id = 'private-docs'
  and (storage.foldername(name))[1] = (select auth.uid())::text
);

This is a SELECT policy shape only. It does not cover INSERT/UPDATE/DELETE, it does not make a public bucket private, and it does not prove your live project is configured this way. It shows the shape of the check the exposure test below must actually pass.

Run the storage-exposure test against your own app

Do this against your real project, through the same client your app uses.

  1. List your buckets and their visibility. In the dashboard or via the API, note which buckets are public. For each public bucket, confirm it contains only genuinely public assets. If any private file lives in a public bucket, that file is already exposed — move it and rotate anything sensitive.
  2. Upload a private file as User A into a private bucket, and note its exact path.
  3. Open the object's URL signed out. A private-bucket object served without a valid signed URL must be refused. If the raw public URL returns the file, the bucket isn't actually private.
  4. Fetch it as User B through the ordinary client (or request a signed URL as User B). The storage policy must deny it. If User B receives User A's file, your policy isn't scoped to the owner.
  5. Positive control: User A can still read their own file.

Do not run this with a service-role key — it bypasses storage policies just as it bypasses table RLS, so it proves nothing about an ordinary user. Use synthetic files, never real customer data.

If a non-owner or a signed-out request gets the file, keep the exact request, identities, and a minimal fixture, and turn it into a test before you change the policy or the bucket.

Know what green does — and does not — mean

A passing test is evidence for one bucket, one operation, on the configuration you have now. It is not a storage audit.

It does not cover your other buckets, INSERT/UPDATE/DELETE on objects, image transformations, or public CDN caching of a file that was public even briefly. And it is separate from — not covered by — the table RLS test: securing documents says nothing about storage.objects. You need both.

The goal is not to call storage "secure." It's to preserve a falsifiable statement:

A file in a private bucket is refused to a signed-out request and to a non-owner, while its owner can still read it.

Your prototype working is the start. A storage layer that refuses the wrong user is evidence you can sell behind.

Inspect the RLS policy model

Run the broader launch-readiness checklist

Sources: Supabase Storage access control and Row Level Security.

Test this on your own app before you sell it.