Cenk Kurtoğlu©26

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

Next.js gotchas that bite this setup

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.

Get the kit - $29 ↗Review the files, scope and license →

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.

Template bundle

Review all 20 frontend starters in one bundle

20 editable frontend starters with live demos are $79 — one payment, no subscription.

View the Bundle — $79↗