Supabase signed URL vs public URL: keep the storage bucket private
getPublicUrl on a public bucket hands a permanent, unauthenticated link to anyone who sees it once. Keep the bucket private, scope the storage policy to auth.uid(), and serve files with createSignedUrl links that expire: 900 seconds in the app, 3600 for a deliberate share.
TL;DR · THE FIX
Public storage URLs are permanent, unauthenticated, and guessable. Keep the bucket private, store files under an owner-prefixed path with a storage policy that checks auth.uid(), and hand out short-lived createSignedUrl links (900s in-app, 3600s for a deliberate share).
The symptom
I needed to show user-uploaded images in the app. The fast path is a public bucket: flip it to public, grab getPublicUrl, drop it in an <img>, done. It works in five minutes, and it’s a quiet data leak. Anyone who ever sees that URL, in a log, a referrer header, a forwarded link, keeps access forever, there’s no per-user check, and predictable paths can be guessed.
What’s happening
A public Supabase bucket serves every object to anyone who has the path, with no authentication. The public URL is stable and permanent, so it can’t be revoked short of deleting or moving the file. For anything user-owned or remotely private, that’s exposure. And “nobody will guess the filename” is not access control, because paths leak into server logs, analytics, and the browser’s referrer, and sequential or name-based paths enumerate.
The fix
Keep the bucket private. Store each file under the owner’s id, add a storage policy that only lets the owner touch their own prefix, and generate a short-lived signed URL when you need to display the file.
Owner-scoped policy on the storage objects:
create policy "owners read their own files"
on storage.objects for select
using (
bucket_id = 'uploads'
and (storage.foldername(name))[1] = auth.uid()::text
);
Upload under that prefix, then sign on demand:
const path = `${user.id}/${file.name}`;
await supabase.storage.from("uploads").upload(path, file);
// short-lived link, generated when you render
const { data } = await supabase.storage
.from("uploads")
.createSignedUrl(path, 900); // 15 minutes
// data.signedUrl -> use in <img src>
Pick the TTL by use: around 900 seconds (15 minutes) for an in-app view the page will refresh anyway, up to 3600 (an hour) for a link the user deliberately shares. The link expires on its own, the policy checks the owner on every signing, and a leaked URL simply stops working.
The lesson
Never use public storage URLs for anything a user owns. A private bucket, owner-prefixed paths, a policy keyed to auth.uid(), and short-lived signed URLs give you the same <img src> convenience while keeping access revocable and per-user. The URL dies on its own, so a leak doesn’t outlive the timer.
Discussion
Powered by GitHub. Sign in to leave a comment.