Supabase security · 6 minute read
Public Supabase bucket: how to stop other sites serving your images
A public bucket is public by design: anyone with the URL can fetch the file, and a plain <img src> request cannot carry a secret. If your images are generated once from query data and reused, the fix is not a secret in the request — it is a private bucket plus short-lived signed URLs minted by your own app. Your backend holds the only key that can produce a valid link.
Why "public" cannot mean "only my app"
The browser makes the image request directly to the Storage URL. It sends no Authorization header, no session cookie, no app signature. So there is nothing in a plain image request for your app to verify — the bucket either allows the request or it does not. Hotlink protection at a CDN (Referer checks) helps against casual scraping, but Referer is spoofable and absent in many contexts. That is a speed bump, not a control.
The pattern: private bucket + deterministic path + signed redirect
Because each image is deterministic from the search criteria, you can hash the criteria into a stable object path, keep the bucket private, and hand out signed URLs that expire. A route handler is the mint point:
// app/api/image/route.ts — server-side only, the service key never reaches the browser
import { createClient } from '@supabase/supabase-js'
const admin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
export async function GET(req: Request) {
const key = new URL(req.url).searchParams.get('key')!
const { data, error } = await admin.storage
.from('generated-images')
.createSignedUrl(key, 3600) // 1 hour
if (error) return new Response('not found', { status: 404 })
return Response.redirect(data!.signedUrl)
}Render with a stable key so repeated searches reuse the same object and you generate each image once:
const stableKey = (q: SearchQuery) =>
`charts/${createHash('sha256').update(JSON.stringify(q)).digest('hex')}.png`
// <Image src={`/api/image?key=${encodeURIComponent(stableKey(q))}`} ... />A leaked link now dies after its TTL, and no one can enumerate the bucket: the listing stays private and only your route handler can sign. Add whichever session/role check fits your app before signing — that is where "only my application" actually gets enforced.
What each option costs you
- Fully public (cheapest, zero code). Fine when images are public marketing content. Watch egress — hotlinking bills you, not them.
- Public + random paths (speed bump). Store under a UUID per dataset so URLs are not guessable. Weak: anyone holding one working URL can still embed it. It does stop enumeration scraping.
- Private + signed URLs (strongest). TTL trades cacheability for control. For generated-once content a 1h TTL plus browser caching on your route is usually the right balance. Storage transforms can still shrink bytes if you serve multiple sizes.
Next.js gotchas that bite this setup
- Signed URLs are absolute external hosts: add your project storage host to
images.remotePatternsinnext.config.jsor the optimizer refuses to serve them. SUPABASE_SERVICE_ROLE_KEYis server-only. If it ever appears in client code or a public env var, the entire posture is gone — check your bundle andNEXT_PUBLIC_names.- Storage files are rows in
storage.objects. "Public bucket" is read access at the bucket level — if you ever need per-user uploads or per-folder reads, the policies on that table are the control, and they have the same RLS failure modes as every other table.
Decide in one question
Ask: if someone embeds my image on their site tomorrow, do I lose money, trust, or nothing? Money or trust → private bucket + signed URLs. Nothing → public with an eye on egress. Most generated-content apps land on the second option right until the bill arrives.
Check every policy and role in one pass
Supabase RLS Audit Kit · $29 one-time
Seven commented SQL audits and 60 checks show every table's RLS status, the role each policy actually targets (including storage.objects), grants and bypass paths, plus a role-simulation harness that proves isolation. Report and fix-SQL templates included.
Related reading: Supabase Storage upload fails with a 403 row-level security error and 7 ways your Supabase app still leaks data with RLS enabled. For a full launch pass, a fixed-price Supabase RLS audit from $99 returns prioritized findings with reproducible evidence.