You wrote storage policies. You tested the upload. It worked. Then someone points out that user A can download user B's invoice by guessing the key, and when you check, the policy is right there in the migration, enabled, correct.
Two things cause this, and both are worth understanding before you write another policy.
Storage objects are rows in a table
Supabase Storage is Postgres underneath. Every object is a row in
storage.objects with a bucket_id, a name (the full key) and an owner.
Access control is ordinary row level security on that table, which is why
policies are SQL and why they can join your own tables.
storage.foldername(name) splits the key into an array of path segments, and
that array is what nearly every policy is written against. So the shape of your
keys is your security model:
<owner-id>/<prefix>/<uuid>-<filename>
[1] [2] [3]
With the owner first, "you may only touch your own files" is a string comparison:
(storage.foldername(name))[1] = (select auth.uid()::text)
With flat keys (uploads/<uuid>.pdf) that comparison is impossible, and
ownership has to be looked up in a table you have to remember to consult. The
day someone forgets is the day the bug at the top of this document appears.
The policy set
Four policies, one per operation. A single for all policy is shorter and hides
exactly the mistake you want to catch: allowing deletes when you meant reads.
drop policy if exists "uploads: read own objects" on storage.objects;
create policy "uploads: read own objects"
on storage.objects for select to authenticated
using (
bucket_id = 'uploads'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
drop policy if exists "uploads: write own objects" on storage.objects;
create policy "uploads: write own objects"
on storage.objects for insert to authenticated
with check (
bucket_id = 'uploads'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
drop policy if exists "uploads: replace own objects" on storage.objects;
create policy "uploads: replace own objects"
on storage.objects for update to authenticated
using (bucket_id = 'uploads' and (storage.foldername(name))[1] = (select auth.uid()::text))
with check (bucket_id = 'uploads' and (storage.foldername(name))[1] = (select auth.uid()::text));
drop policy if exists "uploads: delete own objects" on storage.objects;
create policy "uploads: delete own objects"
on storage.objects for delete to authenticated
using (
bucket_id = 'uploads'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
Three details that matter:
usingversuswith check.usingfilters the rows a statement can see or touch;with checkvalidates rows being written. Anupdatepolicy needs both, or a user can move their object into someone else's prefix.(select auth.uid()). The scalar subselect makes Postgres evaluate the function once per statement instead of once per row. On a listing of a few thousand objects, that is the difference between fast and noticeably slow.- No
anongrants. Nothing in this bucket is readable without a session. If you need public objects, that is a second, deliberately public bucket.
Why your policies might not be running at all
This is the second cause, and it is the one that surprises people.
The service role key bypasses row level security entirely. Not "has a
permissive policy": bypasses. Any code holding
SUPABASE_SERVICE_ROLE_KEY can read, write and delete every object in every
bucket, and the policies above have no effect on it whatsoever.
That key is exactly what a server-side storage helper uses, because signing an upload URL requires it. So in a typical app:
browser ──(anon key + user JWT)──▶ storage ← policies apply
server ──(service role key)─────▶ storage ← policies do NOT apply
Which means: for every request your server makes, the authorisation check in your own code is the only thing standing between a user and someone else's files. The policies are a second line of defence, protecting against direct access with a user token: real and worth having, but not the thing that runs on the path your app actually takes.
Concretely, this route is wide open despite perfect policies:
// DON'T
export async function POST(request: Request) {
const { key } = await request.json();
const url = await getSignedDownloadUrl(key); // service role: no policy applies
return Response.json({ url });
}
It signs any key anyone asks for. The fix is an ownership check in code:
const user = await requireUser();
if (!isOwnedBy(key, user.id)) return Response.json({ error: "Not found." }, { status: 404 });
const url = await getSignedDownloadUrl(key, { expiresIn: 900 });
Return 404 rather than 403 for someone else's key, incidentally: 403 confirms the object exists.
Testing a policy properly
A test that uses the service role key proves nothing. Test as a user:
import { createClient } from "@supabase/supabase-js";
import { expect, test } from "vitest";
test("a user cannot read another user's object", async () => {
const supabase = createClient(url, anonKey);
await supabase.auth.signInWithPassword({ email: "a@example.com", password });
const { data, error } = await supabase.storage.from("uploads").download("user-b-id/uploads/x.pdf");
expect(data).toBeNull();
expect(error).not.toBeNull();
});
Cover all four operations with at least one wrong-owner case each. The asymmetric failure (reads locked down, deletes not) is common and lets one customer destroy another's files.
Team and shared access
When ownership is a team rather than a person, keep the same shape and let the policy join your tables:
create policy "team-assets: read own team"
on storage.objects for select to authenticated
using (
bucket_id = 'team-assets'
and (storage.foldername(name))[1] in (
select team_id::text from team_members where user_id = (select auth.uid())
)
);
Index team_members(user_id). This subquery runs for every row the statement
touches, and a listing endpoint touches a lot of rows.
The checklist
- Keys start with the owner, and
objectKey()is the only thing that builds them. - One policy per operation;
updatehas bothusingandwith check. auth.uid()wrapped in a scalar subselect.- No
anongrants on a private bucket. - Every server route that signs anything checks ownership in code, because the service role ignores the policies.
- Tests act as a user, and cover a wrong-owner case for read, write, update and delete.