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.
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.
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.
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.
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.
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.