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

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:

```sql
(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.

```sql
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:

```ts
// 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:

```ts
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:

```ts
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:

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

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
