Use signed URLs, never public storage URLs

2 min read SupabaseSecurity

A public Supabase bucket is the quick way to show user uploads, and a permanent data leak. Private bucket, owner-scoped policies, and short-lived signed URLs give you the same convenience without handing out forever-links.

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 actually 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, not a feature. And “nobody will guess the filename” is not access control: 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 actually 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 that 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. 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; the leak doesn’t outlive the timer.

Related fixes

Discussion

Powered by GitHub. Sign in to leave a comment.