Public Supabase storage buckets: the silent data leak
Supabase Storage has one toggle that quietly decides whether your users' files are private or visible to the entire internet: the bucket's public flag. Set it to true and every object in that bucket becomes readable by anyone who knows — or can guess — the URL. No login. No token. No Row Level Security check. Just a plain GET request.
This is the kind of bug that never shows up in testing, because the happy path works perfectly. Uploads succeed, images render, PDFs download. The only thing that changed is that your access control is now "knowing the path" — and paths leak constantly, through referrer headers, browser history, shared links, server logs, and CDN caches.
What "public" actually means
A Supabase bucket is backed by a row in storage.buckets with a boolean public column. That one column changes the entire authorization model for reads:
Private bucket (public: false) | Public bucket (public: true) | |
|---|---|---|
| Read access | Requires a signed URL or an RLS-allowed request | Anyone with the object URL |
| Authentication on download | Enforced | None |
RLS on storage.objects for reads | Evaluated | Not consulted for public object GETs |
| Safe for user uploads? | Yes | Only if every file is meant to be world-readable |
The flag exists for a legitimate reason — truly public assets like marketing images, avatars you've decided are non-sensitive, or open game assets. The problem is that public: true gets copied into buckets that hold things that are emphatically not public: invoices, ID documents, medical scans, contracts, private message attachments, exports of someone's data.
Why URL secrecy is not security
The usual rationalization is "the file names are random, so nobody can find them." That's security through obscurity, and it fails in ordinary, non-adversarial ways:
- Object paths are not secret. A public object URL is
https://<project-ref>.supabase.co/storage/v1/object/public/<bucket>/<path>. The bucket name is right there in the URL, and path conventions likeuser-123/invoice-2026-04.pdfare guessable and enumerable. - URLs leak by default. They land in
Refererheaders sent to third parties, in analytics and error trackers, in proxy and CDN logs, in screenshots and support tickets, and in shared chat messages. - Public buckets are list-friendly. With a permissive
selectpolicy onstorage.objects, a public bucket can expose object listings — turning "guess the path" into "read the index." - It's permanent once fetched. Search engines and scrapers cache public URLs. Flipping the bucket private later does not un-leak what was already downloaded.
So the real blast radius of one public bucket holding user uploads is: every file every user ever uploaded, retroactively, to anyone who has — or finds — a URL.
The mistake in code
Here's the canonical version. A bootstrap function provisions a bucket for user invoices and sets it public — usually because that was the fastest way to get image previews rendering during a demo, and it never got walked back:
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
// Provision the bucket that stores user-uploaded invoices (PDFs).
export async function ensureInvoiceBucket() {
const { error } = await supabase.storage.createBucket("invoices", {
public: true,
fileSizeLimit: "10MB",
});
if (error) throw error;
}
This runs once, succeeds, and is forgotten. From that moment, every invoice in the invoices bucket is reachable at a public URL. Here's exactly what GuardLayer reports on this file — live engine output, not a mockup:
- Criticallib/storage/buckets.ts:10
Public storage bucket
Set public: false and serve files through signed URLs (createSignedUrl) or RLS-protected access. Only keep a bucket public for genuinely public assets.
The same flag shows up in SQL migrations, too — insert into storage.buckets (id, name, public) values ('invoices', 'invoices', true) or update storage.buckets set public = true — and it's just as dangerous there. GuardLayer flags both forms.
The fix
1. Create the bucket private. The core change at creation time is public: false. Since this bucket only ever holds invoice PDFs, also constrain what can land in it:
const { error } = await supabase.storage.createBucket("invoices", {
public: false,
fileSizeLimit: "10MB", // also accepts a number of bytes, e.g. 10 * 1024 * 1024
allowedMimeTypes: ["application/pdf"],
});
2. Serve files through short-lived signed URLs. A signed URL is a time-boxed, cryptographically signed link to a single object. Generate it server-side, after you've authorized the request, hand it to the user, and let it expire:
// Server-side only — runs after you've confirmed the user owns this file.
const { data, error } = await supabase.storage
.from("invoices")
.createSignedUrl(`${userId}/invoice-2026-04.pdf`, 60); // expiresIn: 60 seconds
if (error) throw error;
return data.signedUrl;
The client never holds long-lived access. Each link works for one object, for a few seconds, for the user you handed it to.
3. Lock down access with storage RLS. Storage objects live in storage.objects, a normal table you can write policies against. Scope reads and writes to the owning user, keying on the first path segment (the per-user folder):
-- Users can only read their own files in the invoices bucket.
create policy "own invoices read"
on storage.objects for select
to authenticated
using (
bucket_id = 'invoices'
and (storage.foldername(name))[1] = auth.uid()::text
);
-- ...and only write into their own folder.
create policy "own invoices write"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'invoices'
and (storage.foldername(name))[1] = auth.uid()::text
);
These policies govern access through the authenticated anon-key path. With the bucket private, that's the path every read takes — so the policies are actually enforced, instead of being silently skipped as they are for public object GETs.
4. If it already shipped public, fix it now. Set the bucket private in the dashboard (or run update storage.buckets set public = false where id = 'invoices'), then treat every object that was in it as already exposed — re-issue or rotate anything sensitive, the same way you'd treat a leaked secret. Making the bucket private does not retract URLs that have already been fetched and cached.
30-second self-check
# 1. Any bucket created or updated as public in code?
grep -rnE "createBucket|updateBucket" . | grep -i "public"
# 2. Any migration flipping storage.buckets to public?
grep -rniE "storage\.buckets" . | grep -iE "public|true"
# 3. From the dashboard SQL editor — list every public bucket:
# select id, name, public from storage.buckets where public = true;
If a bucket holding anything user-specific comes back public, that's the leak.
FAQ
Is it ever fine to have a public bucket? Yes — for genuinely public, non-sensitive assets: logo images, open documentation, marketing media. The rule of thumb: if you'd be comfortable posting any object's URL on a billboard, public is fine. Otherwise it's private.
Aren't the file paths random enough to be safe? No. URL secrecy is not access control. Paths leak through referrer headers, logs, and shared links, and public buckets can expose listings. Treat "knowing the URL" as equivalent to "no protection."
Do RLS policies protect a public bucket?
Not for public object reads. A public bucket serves objects without consulting your read policies — Supabase's own docs note RLS "is not needed for public buckets, as they are already publicly accessible." RLS on storage.objects only meaningfully protects reads when the bucket is private and access goes through the authenticated anon-key or signed-URL path.
Signed URLs vs. a public bucket — what's the practical difference? A public URL works forever, for anyone. A signed URL works for one object, for a short window, for the person you issued it to. For anything user-owned, signed URLs are the correct default.
How long should a signed URL live? As short as the workflow allows — seconds to a few minutes for a direct download or render. Generate a fresh one each time rather than caching long-lived links.
Catch this before it ships — free
GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.