Skip to content

Row level security on storage buckets, and why yours might not be running

Storage policies are SQL against storage.objects keyed on the path. Here is the policy set that works, the key layout it depends on, and why the service role key silently bypasses all of it.

Supabase Storage4 min readships at docs/solutions/supabase-storage/rls-on-storage-buckets.md

Tags: supabase · storage · rls · postgres · security · policies

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:

  • using versus with check. using filters the rows a statement can see or touch; with check validates rows being written. An update policy 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 anon grants. 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; update has both using and with check.
  • auth.uid() wrapped in a scalar subselect.
  • No anon grants 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.