DEV Community

Veristria
Veristria

Posted on Originally published at usevibeguard.com

Your Supabase Storage bucket is public - signed URLs will not save you

Problem

You are generating private B2B images with Sharp in a Next.js API route and storing the derivatives in Supabase Storage. The bucket that holds those images is currently public, meaning anyone who can guess or discover the object URL can download the original or any derivative without authentication. This defeats the “private‑only” intent of your workflow and can expose sensitive business assets (e.g., brand assets, customer logos, confidential diagrams).

Mechanism

Supabase Storage buckets have a public flag. When public = true the bucket’s objects are served directly via a public URL (https://<project>.supabase.co/storage/v1/object/public/<bucket>/<path>). No auth check is performed, and the URL is stable as long as the object exists.

When you later generate presigned URLs (e.g., storage.from('avatars').createSignedUrl(...)) you rely on the bucket being private; the signed URL adds a time‑limited token that Supabase validates before serving the object. If the bucket is public, the signed URL is unnecessary and the underlying object can be fetched without it.

Detection

A quick read‑only audit (no credentials required) can confirm the bucket’s exposure:

-- RowShield‑style detection query
SELECT id, public
FROM storage.buckets
WHERE id = 'avatars';   -- replace with your bucket name
Enter fullscreen mode Exit fullscreen mode

If the result shows public = true, the bucket is publicly readable.

You can also verify manually:

curl -I "https://<project>.supabase.co/storage/v1/object/public/avatars/example.jpg"
# Expect HTTP 200 even without auth → bucket is public
Enter fullscreen mode Exit fullscreen mode

Fix

  1. Make the bucket private
-- Turn off public access
UPDATE storage.buckets
SET public = false
WHERE id = 'avatars';   -- replace with your bucket name
Enter fullscreen mode Exit fullscreen mode
  1. Add a row‑level security (RLS) policy that limits reads to the owning user (or to a service account you control). Example for per‑user folders:
-- Allow authenticated users to read only objects in their own folder
CREATE POLICY avatars_read_own
ON storage.objects
FOR SELECT
TO authenticated
USING (
  bucket_id = 'avatars' AND
  (storage.foldername(name))[1] = (auth.uid())::text
);
Enter fullscreen mode Exit fullscreen mode
  1. Generate expiring signed URLs in your Next.js API route instead of returning raw URLs:
// pages/api/image.ts
import { createClient } from '@supabase/supabase-js';
import type { NextApiRequest, NextApiResponse } from 'next';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!   // server‑side only
);

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { bucket, path, expiresIn } = req.query as {
    bucket: string;
    path: string;
    expiresIn?: string;
  };

  // Validate request (e.g., check auth, ownership, etc.)
  // ...

  const { data, error } = await supabase.storage
    .from(bucket)
    .createSignedUrl(path, Number(expiresIn) || 60); // seconds

  if (error) {
    res.status(500).json({ error: error.message });
    return;
  }

  res.status(200).json({ url: data?.signedUrl });
}
Enter fullscreen mode Exit fullscreen mode
  1. Update your client to request the signed URL from the API route and use that URL for <img> tags or downloads.

  2. Optional: Queue heavy Sharp processing

    If derivative generation is CPU‑intensive, offload it to a background worker (e.g., Supabase Edge Functions, a separate Node worker, or a cloud queue). Store the resulting files in the now‑private bucket, then serve them via the signed‑URL endpoint above.

Caveats

Issue Impact Mitigation
Service‑role key exposure If the key leaks, anyone can bypass RLS. Store it only on the server (e.g., Vercel/Netlify serverless functions). Never bundle it in client code.
Policy granularity The example policy assumes a flat userId/filename layout. Adjust the USING clause to match your folder structure (e.g., projectId/userId/...).
Signed‑URL TTL Too short → frequent re‑fetches; too long → larger attack window. Choose a TTL that balances UX and security (commonly 60–300 seconds).
Bucket‑wide public flag Changing public to false affects all objects, including any that truly need public access (e.g., marketing assets). Split assets into separate buckets: one private for B2B images, one public for truly public content.
Cache invalidation Browsers may cache a signed URL longer than its TTL if Cache-Control headers aren’t set. Set Cache-Control: private, max-age=0 on the response or on the object metadata.

Next steps

  1. Run the read‑only audit (SELECT id, public FROM storage.buckets) to confirm the bucket’s current state.
  2. Apply the UPDATE statement to make the bucket private.
  3. Deploy the RLS policy that matches your ownership model.
  4. Update your Next.js API route to issue signed URLs and replace any direct object URLs in the UI.
  5. Test end‑to‑end: unauthenticated request → 403, authenticated request → signed URL → successful image load.

By tightening the bucket’s visibility and gating access through signed URLs generated in a server‑side Next.js route, you close the primary exposure vector while preserving the ability to serve private B2B images efficiently.

All audit queries are read‑only and require no credentials.

Top comments (0)