An open storage.objects policy is a public bucket with extra steps
The bucket flag says private. Objects download anyway. When that combination turns up, the mechanism is almost always a policy on storage.objects — the table underneath the storage API — granting reads more broadly than the toggle suggests. Flag and policies are separate layers, and whichever is more generous wins, which is how a nominally private bucket ends up publishing.
This page explains how storage authorisation composes, gives you the catalog query that shows what your policies actually grant, and walks the rewrite from a bucket-wide grant to an owner-scoped one. It pairs with storage-bucket-public-images, which covers the plainer case of the public flag itself.
The practical difference between the layers follows from the mechanics. A flag has one meaning and one place to look; policies are arbitrary logic, written under deadline, editable by anyone with console access, and invisible unless someone goes looking. That asymmetry is why the private-flag-but-open-policy state survives reviews that feel thorough.
Rules that check this
- highStorage bucket is public
PUBLIC_BUCKET_EXPOSURE
Two layers decide who reads an object
Storage in Supabase is Postgres underneath: objects live in a storage.objects table, and the storage API consults that table's row-level security policies on every authorised request. The bucket's public flag is a shortcut in front of this machinery — public skips authorisation for the unauthenticated route entirely, while private defers completely to the policies beneath.
The failure shape follows. A private bucket with a policy granting SELECT broadly — USING (true), or any condition matching every object regardless of owner — lets the authorised routes hand files to identities far wider than intended, occasionally including anon itself. Everyone reviewing the bucket toggle sees discipline; nobody re-reads the policy doing the opposite.
See what your policies grant
The catalog lists every storage policy with its full condition text, and reading it settles arguments quickly — look for conditions that hold regardless of which bucket or which user is asking. Pay particular attention to SELECT policies on busy buckets, because those decide downloads:
SELECT policyname, cmd, roles, qual, with_check FROM pg_catalog.pg_policies WHERE schemaname = 'storage' AND tablename = 'objects' ORDER BY policyname;
The owner-scoped rewrite
Conditions worth pausing on: a bare true; comparisons against bucket_id that omit any notion of owner; anything referencing no user column at all. Policies naming only the bucket grant every object in it to every role listed — generous by construction, whatever the original intention was.
The conventional rewrite scopes by folder, with one folder per user keyed by their identifier, and grants reads only within it. Adjust the bucket name and layout to your own scheme rather than adopting the sample wholesale, and test in staging first: these policies sit beneath the whole storage API, and an over-tight condition can lock uploads out entirely.
CREATE POLICY "uploads_owner_read"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'uploads'
AND (storage.foldername(name))[1] = (SELECT auth.uid())::text
);Keep the invariant explicit
Whatever scheme you choose, hold one invariant deliberately: every granted row must be attributable to the caller through some column or path segment. Where that attribution cannot be expressed honestly, the alternatives are signed URLs minted server-side or a dedicated public bucket for genuinely public assets — never a wider policy on the shared one.
Stated plainly: RowShield's automated storage check reads bucket metadata and raises PUBLIC_BUCKET_EXPOSURE when a bucket is flagged public; a permissive objects policy on a nominally private bucket is the state this page equips you to find with the query above. Coverage honesty matters more than tidiness — the nine shipped rules define the ceiling of automated claims, and this variant sits outside it.
Connected projects still benefit from drift watching: changes between scans are diffed and reported as appeared, came back or disappeared, so edits to storage policies surface as named transitions even where no rule fires on the state itself. Run a free audit — no account — at rowshield.dev/audit for the automated half. RowShield remains an independent product, unaffiliated with and not endorsed by Supabase.
Frequently asked
- Why did setting the bucket to private change nothing?
- Because the flag only controls the unauthenticated shortcut route. Authorised routes consult the storage.objects policies, and a broadly written SELECT policy keeps serving objects no matter what the flag says.
- Is editing storage.objects policies risky?
- Treat it as production surgery: the table backs the entire storage API, and an over-tight policy can break every upload and download simultaneously. Change it in staging first, with a rollback statement ready.
- Does the scanner inspect objects policies automatically?
- No. The automated rule covers bucket visibility metadata; permissive policies on private-flagged buckets are found with the catalog query on this page, and policy edits still surface through drift diffing between scans.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit