RowShield
Guides

Signed URLs or a public bucket: deciding properly

Public bucket versus signed URLs is presented as a religious argument, usually by people who chose once and generalised. Both models are correct for large classes of content, and the honest way to decide is to compare what each promises: permanence versus scope, simplicity versus control.

This page sets out both sides without a predetermined winner, gives the rubric we apply, and covers the migration path when a bucket outgrows its original answer. The monitoring half — knowing which buckets are currently public — is automated.

Rules that check this

What a public bucket actually promises

A public bucket serves any object to anyone presenting its URL, permanently, with no authentication and no expiry. The URL is a capability: possession is authorisation, and since object paths are frequently derived from filenames, timestamps or sequential ids, assume guessability. Nothing about the arrangement is secret, revocable after the fact, or per-user.

For content whose audience really is everyone — product imagery, documentation, avatars users expect to share — those properties are features. The CDN-friendly path, the absence of signing round-trips, and link permanence all serve exactly that use case. The failure mode is drift, not design: buckets created for internal uploads defaulting their way into becoming public archives because nothing flagged the transition.

What a signed URL actually promises

A signed URL binds access to one object, for one span of time, granted at the moment of minting by code you control. The signature encodes expiry, so distribution channels cannot outlive the grant; changing a user's permissions affects what your server will mint for them next; and nothing is discoverable, because there is no stable public namespace to enumerate.

The costs are operational rather than financial. Minting happens server-side or under whatever policy permits it, so a route or function sits between the user and the file. Expiring URLs complicate caching, since each mint produces a distinct string that shared caches treat as distinct content. And long-lived references — an email linking to a report — need expiry windows generous enough to survive forwarding, which quietly narrows the security gap being purchased.

// Server route: short-lived grant, minted per request
const { data } = await supabaseAdmin
  .storage
  .from('reports')
  .createSignedUrl('users/9f2/invoice.pdf', 60 * 10);

The decision rubric

Four questions separate the cases cleanly. Who is the audience: everyone, or identified users and groups? What is the lifetime of legitimate access: indefinite, or bounded by a session, a subscription, or an event? Is revocation ever required: recall obligations, deleted accounts, withdrawn documents? And does the content carry consequence if enumerated: personal data, unreleased material, anything a competitor could mine? Two or more answers pointing toward control mean signed URLs; all four pointing toward openness mean a public bucket is simply the right tool, chosen knowingly.

Migrating a bucket that drifted public

The flip is one statement; the care is around it. Inventory consumers of the current public URLs first, because they break at the moment the flag changes. Move minting behind a server route scoped to whatever permission model the bucket now needs. Flip, deploy the route, and watch for hotlink breakage from anywhere you do not control. Consumers inside your own app migrate with it; strangers who embedded your URLs never had a contract.

UPDATE storage.buckets SET public = false WHERE id = 'uploads';

Monitoring the choice, whichever you make

The rubric is human judgement and stays human — RowShield does not decide whether a given bucket should be public. What it automates is the vigilance layer: every scan reads bucket flags, reports each public bucket as a finding with remediation SQL generated from your real bucket names, and treats a flip from private to public as drift worth alerting on, so the decision you made remains the decision deployed. Reads touch metadata only, requests stay GET, HEAD and OPTIONS, and RowShield is an independent product, not affiliated with or endorsed by Supabase. See your current bucket posture at rowshield.dev/audit — the free probe needs no signup.

Frequently asked

Do signed URLs encrypt or hide file contents?
No. A signed URL gates access in time and scope; the bytes themselves travel exactly as stored. Content confidentiality requires encryption at rest handled elsewhere, which is a separate decision from URL strategy.
How long should a signed URL live?
As long as the legitimate session needs and materially no longer — minutes for inline viewing, perhaps hours for a downloaded report a user may revisit. Every extension of the window narrows the advantage over a public link, so treat long expiries as a smell.
Can clients mint their own signed URLs?
Only if a policy on storage.objects permits it, and granting that broadly recreates the public-bucket situation with extra steps. Treat minting as privileged server behaviour unless you have a specific reason to delegate it.

Check your project in about ten seconds

Paste a URL. No signup, no writes, nothing stored.

Run the free audit
supabase signed urls vs publicsupabase createsignedurlsupabase private bucket