VibeSecurity

Guide

Private file uploads with Supabase Storage: policies and signed URLs

User documents such as IDs, invoices and medical files should never sit in a public bucket. This guide sets up a private bucket where each user can only reach files inside a folder named after their own user id. You will add size and type limits, serve files through short-lived signed URLs and run a test that shows a second user gets nothing.

By the VibeSecurity team4 min read

What you need and what you will have at the end

You need a Supabase project you own, the JavaScript client, and two test users. The plan is a bucket called documents, where every file path starts with the owner's user id, such as the user id followed by a slash and a file name.

At the end uploads work only into the caller's own folder, other users cannot read or overwrite the files and downloads happen through links that expire.

Step 1: Create a private bucket with limits

Buckets are private by default, which means access goes through storage policies rather than open URLs. Bucket-level options set the maximum file size and the allowed mime types. Create the bucket from a trusted place such as a one-off server script or the dashboard, not from browser code, using the service role key.

Mime types are matched against what the client claims, so treat this as a guard against honest mistakes and cheap abuse and not as content validation. Scan or re-check files server-side if the content matters.

One-off server script
import { createClient } from "@supabase/supabase-js";

const admin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

const { data, error } = await admin.storage.createBucket("documents", {
  public: false,
  allowedMimeTypes: ["application/pdf", "image/png", "image/jpeg"],
  fileSizeLimit: "5MB",
});

console.log(data, error);

Step 2: Write the folder policies

Storage access is controlled with policies on the storage.objects table. The helper storage.foldername(name) returns the folders of a file path as an array, so the first element is the top folder. Comparing it with the caller's user id restricts each person to their own folder. The id must be text, so cast the uuid.

Uploading needs an insert policy. Reading and creating signed URLs need select. Replacing a file with upsert needs select and update, and removing needs delete. Give each operation its own policy and always include the bucket id so the rule does not spill onto other buckets.

SQL editor
create policy "Users upload to own folder"
on storage.objects for insert
to authenticated
with check (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.uid())::text
);

create policy "Users read own folder"
on storage.objects for select
to authenticated
using (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.uid())::text
);

create policy "Users update own folder"
on storage.objects for update
to authenticated
using (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.uid())::text
)
with check (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.uid())::text
);

create policy "Users delete own folder"
on storage.objects for delete
to authenticated
using (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.uid())::text
);

Step 3: Upload from the app

Build the path on the client from the signed-in user's id and never from a value the user types. The policy enforces it anyway, but building it correctly avoids confusing errors. Use a random file name so users cannot collide, and keep the original name in your database if you need it.

Client code
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Not signed in");

const path = user.id + "/" + crypto.randomUUID() + ".pdf";

const { error } = await supabase.storage
  .from("documents")
  .upload(path, file, { contentType: "application/pdf" });

if (error) throw error;

Step 4: Serve downloads with signed URLs

A signed URL grants access to one private file for a limited time. The second argument is the lifetime in seconds. Generate it on demand when the user opens the file, keep the lifetime short and never store the URL. The signing call runs under the caller's policies, so it fails for files the caller cannot select.

Client code
const { data, error } = await supabase.storage
  .from("documents")
  .createSignedUrl(path, 60);

if (error) throw error;
window.open(data.signedUrl);

Step 5: Test with two users

Sign in as user A and upload a file. Then sign in as user B and try to reach A's file three ways: create a signed URL for it, download it, and upload a file into a path that starts with A's id. All three should return an error. Then, as user A, confirm the file still opens.

Also try to upload a disallowed type and an oversized file as A. The bucket limits should refuse both.

Test script, signed in as user B
const a = await supabase.storage
  .from("documents")
  .createSignedUrl(USER_A_ID + "/" + FILE_NAME, 60);
console.log("signed url for A's file:", a.data, a.error);

const b = await supabase.storage
  .from("documents")
  .upload(USER_A_ID + "/planted.pdf", new Blob(["x"], { type: "application/pdf" }));
console.log("upload into A's folder:", b.data, b.error);

Common mistakes

How to verify

The pass condition is that user B gets an error for every attempt on A's folder, user A can upload, list and open their own files, an unsigned request to a file URL is refused and oversized or wrong-type uploads are rejected. If B gets a URL or data back for A's file, a policy is missing its folder check.

Keep it working

Re-run the two-user test whenever you add a bucket, change a storage policy or let an AI tool rewrite upload code. Add each new bucket's policies in the same migration that creates it.

Frequently asked questions

Is a private bucket enough on its own?

A private bucket denies open URLs, but signed-in access is governed by your storage policies. Without a policy that ties the folder to the user, any policy that matches the bucket alone will let every signed-in user see every file.

How long should a signed URL last?

As short as your use case allows. The expiry is set in seconds when you create it. For opening a document in the browser, a minute or so is usually enough, and you can generate a new one on each click.

Do mime type limits validate file contents?

No. They check the type the client declares. Someone can label a file with an allowed type. For sensitive flows, verify the content on the server after upload, for example by checking file signatures or scanning it.

Which policies does an upsert need?

Supabase documents that replacing a file with upsert requires select and update permissions in addition to insert. If overwrites fail with a permission error, check that those two policies exist for the folder.

Sources

  1. 1.Supabase docs: Storage access control
  2. 2.Supabase docs: Creating buckets
  3. 3.Supabase docs: Serving downloads
  4. 4.Supabase docs: Row Level Security