Private buckets refuse anonymous requests, which is what you want. But your app still needs to show a user their own invoice or let them download an export. A signed URL solves this: your server, which holds the credentials, asks the storage service to create a link for one specific file that works for a limited time. The browser can then fetch the file directly.
A signed URL is a bearer credential. Anyone who obtains the link can use it until it expires, and the storage service does not check who they are. Problems appear when generated code sets very long expiry times to avoid handling refreshes, stores signed links in the database as if they were permanent, or exposes an endpoint that will sign a URL for any file path the caller asks for without checking that the file belongs to them.
Create signed URLs on the server, only after confirming that the logged-in user is allowed to see that file. Keep the lifetime short, generate a fresh link each time it is needed and avoid logging or emailing the links. The same applies to signed upload URLs: limit them to a single path and a short window.
const { data, error } = await supabase.storage
.from("documents")
.createSignedUrl(`${user.id}/invoice.pdf`, 60);