# Protecting Supabase Storage with RLS: designing file access control with buckets, per-user folders, and signed URLs

> An implementation guide that designs Supabase Storage access control with RLS policies on storage.objects. With official-compliant real code: no policy means no upload (default deny), the 'uid folder = only your files' pattern via bucket_id and storage.foldername(name), public/private buckets and signed URLs, defense in depth with file_size_limit/MIME restrictions, and safe upload from Next.js.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Supabase, RLS, PostgreSQL, セキュリティ, Next.js
- URL: https://tomodahinata.com/en/blog/supabase-storage-rls-access-control-bucket-folder-policies-guide
- Category: Databases & RLS
- Pillar guide: https://tomodahinata.com/en/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## Key points

- A Supabase Storage file is a row in the storage.objects table, and access control is done with RLS there. With no policy, you can neither upload nor read (default deny).
- The standard pattern is 'the leading folder name = the user's uid.' With (storage.foldername(name))[1] = (select auth.jwt()->>'sub'), confine each user to their own uid folder.
- Write a bucket_id match + an ownership condition per INSERT/SELECT/UPDATE/DELETE. With owner_id, narrow to 'only the rows I uploaded.'
- A public bucket is readable by anyone with the URL = RLS doesn't apply to reads. Distribute confidential files with a private bucket + a time-limited signed URL (createSignedUrl).
- RLS only looks at the path and owner_id. Defend file size and MIME type in depth with bucket restrictions + server-side validation, and don't trust the client-derived filename and Content-Type.

---

"I added an image-upload feature and **user A could guess another person's file URL and download it**," "a bucket I thought was private was actually **public to the whole world**" — once you start handling files with Supabase Storage, you need another layer of access-control design separate from the database's RLS.

The key fact is this: **a Supabase Storage file is a "row" in a table called `storage.objects`.** So access control is also done with **row-level security (RLS) on `storage.objects`**, just like a table. And Supabase official states plainly — **"with no RLS policy, Storage doesn't permit any upload to a bucket"** ([Supabase: Storage Access Control](https://supabase.com/docs/guides/storage/security/access-control)). It starts from the same **default deny** as the database.

This article explains, official-compliant, how to implement Storage access control on the four points of **bucket design, per-user folders, signed URLs, and defense in depth.** The subject is the [real-time match-recording app](/case-studies/realtime-sports-scoring-app) (handling player photos and match data, operating RLS on all tables). The content is faithful to [Supabase official](https://supabase.com/docs/guides/storage/security/access-control) and [PostgreSQL RLS official](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) (as of June 2026).

> **Premise**: the basics of RLS (USING/WITH CHECK, roles, default deny) are covered in [RLS for beginners](/blog/supabase-rls-getting-started-enable-first-policy-guide). This article is the application of that to Storage (`storage.objects`). API details of signed URLs and bucket settings can change, so confirm the latest in the official docs before production.

---

## 1. Mental model: a file is a row, a folder is virtual

First, nail down the correct view of Storage.

- **Bucket** = a container for files. It has whether it's `public` or `private`.
- **Object** = one file. Its substance is one row in the `storage.objects` table, with columns like `bucket_id`, `name` (path), and `owner_id` (the user who uploaded).
- **Folder** = not a physical hierarchy but a **virtual concept** of slash-delimited parts within `name` (the `avatars` and `<uid>` of `avatars/<uid>/photo.png`).

Here's what matters. That a folder is "a string within the `name` column" means **you can write folder-level access control in RLS by making the `name` path a predicate.** Supabase provides helper functions for that.

| Helper | What it returns | Example (`name = 'avatars/uid-123/a.png'`) |
| --- | --- | --- |
| `storage.foldername(name)` | An array of folder names `text[]` | `{'avatars','uid-123'}` |
| `storage.filename(name)` | The filename | `'a.png'` |

`(storage.foldername(name))[1]` gets the **leading folder name** — this is the heart of the next chapter's standard pattern.

---

## 2. The standard pattern: the leading folder = the user's uid

The most reusable Storage RLS design is **"confine each user's files to a folder named with that user's uid."** Make the path the convention `<uid>/filename`, and in RLS permit "only rows whose leading folder name matches your own uid."

```sql
-- アップロード：自分のuidフォルダにしか置けない
create policy "Users can upload to own folder"
on storage.objects for insert to authenticated
with check (
  bucket_id = 'user-files'
  and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);

-- 参照：自分のuidフォルダのファイルしか見えない
create policy "Users can read own folder"
on storage.objects for select to authenticated
using (
  bucket_id = 'user-files'
  and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);

-- 更新（上書き）：自分のフォルダ内だけ
create policy "Users can update own folder"
on storage.objects for update to authenticated
using (
  bucket_id = 'user-files'
  and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);

-- 削除：自分のフォルダ内だけ
create policy "Users can delete own folder"
on storage.objects for delete to authenticated
using (
  bucket_id = 'user-files'
  and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
```

Three points.

1. **Always include the `bucket_id` match.** Without it, the policy takes effect on (or fails to take effect on) files in other buckets too. Since a bucket is a "namespace," bind it in all policies.
2. **`auth.jwt()->>'sub'`** is the user's uid. `auth.uid()` is synonymous. Wrapping with `(select ...)` is the [performance optimization that prevents per-row re-evaluation](/blog/supabase-rls-performance-optimization-select-wrap-index-guide).
3. **Narrowing by `owner_id` is also an option.** To express "only the person who uploaded," you can write it with the ownership column instead of the path.

```sql
-- owner_id（アップロード者）で絞る別解
create policy "Individual user access"
on storage.objects for select to authenticated
using ( (select auth.jwt()->>'sub') = owner_id );
```

The `foldername` approach is "split per folder by path convention," and the `owner_id` approach is "split by who uploaded." Choose by **whether sharing is needed (per folder) or purely individual ownership (owner_id).**

---

## 3. Public and private buckets: don't mistake the range RLS applies to

This is the biggest misconception point. **Files in a public bucket are readable by anyone who knows the URL, regardless of RLS.**

| Bucket type | Read (GET) | Where to use |
| --- | --- | --- |
| **public** | **Anyone** can read with the public URL (RLS doesn't apply to reads) | Logos, public avatars, etc., needing no secrecy |
| **private** | Auth + RLS, or a **signed URL** is needed | Personal documents, paid content, confidential images |

In other words —

> **You must not put confidential files in a "public bucket."** A public bucket isn't "safe because the URL is hard to guess." The moment the URL leaks, anyone can access it. If secrecy is needed, **always make it a private bucket.**

Even for a public bucket, **RLS applies to writes (INSERT/UPDATE/DELETE).** A public avatar of "anyone can read, but only the person can write" is realized with a public bucket + write RLS.

### Distribute private files safely: signed URLs

To show a private-bucket file to the browser, issue a **time-limited signed URL.** A signed URL is a temporary URL that "anyone can open this specific file, only for this duration," generated on the server side.

```ts
// Server Action / Route Handler で、本人確認の後に署名URLを発行する
const supabase = await createClient();
const {
  data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error("unauthenticated");

// 60秒だけ有効なURL。期限はユースケースに合わせ最小に
const { data, error } = await supabase.storage
  .from("user-files")
  .createSignedUrl(`${user.id}/report.pdf`, 60);
if (error) throw error;
// data.signedUrl をクライアントに返す
```

**The key point of the design**: make the signed URL's expiry **minimal for the use** (tens of seconds for a preview, a few minutes to a few hours for a download link distributed by email). Too long an expiry is the same as "effectively public." Also, before issuing a signed URL, **confirm on the server side "may this user view this file"** — issuing a signed URL can bypass authorization, so the issuing logic itself becomes the authorization boundary.

---

## 4. Defense in depth: protect what RLS doesn't look at (size, MIME) with another layer

RLS looks at **the path and owner**, but not **the file's content, size, or true MIME type.** Without hardening this in another layer, you can't prevent "putting a 10GB executable in your own folder" or "uploading a malicious file faking `image/png`."

### Bucket-level restrictions

At bucket creation, you can set a **size limit and allowed MIME types.** This is the first gate.

```ts
// サーバー側（service_role）でバケットを作成し、制限を構造的に課す
await supabaseAdmin.storage.createBucket("user-files", {
  public: false,
  fileSizeLimit: "5MB",
  allowedMimeTypes: ["image/png", "image/jpeg", "image/webp"],
});
```

### Server-side validation (don't trust Content-Type)

Bucket restrictions are convenient, but **the Content-Type the client declares can be spoofed.** To truly harden it, route uploads **through a Server Action**, validate the size and the actual byte content (magic number) on the server, and then pass it to `storage`.

```ts
// app/upload/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { z } from "zod";

const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp"]);

export async function uploadAvatar(formData: FormData) {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user) throw new Error("unauthenticated");

  const file = z.instanceof(File).parse(formData.get("file"));
  if (file.size > MAX_BYTES) throw new Error("file too large");
  if (!ALLOWED.has(file.type)) throw new Error("unsupported type");

  // パスは必ず uid 始まり。client由来のファイル名はサニタイズして使う
  const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
  const path = `${user.id}/${crypto.randomUUID()}-${safeName}`;

  const { error } = await supabase.storage
    .from("user-files")
    .upload(path, file, { contentType: file.type, upsert: false });
  if (error) throw error;
}
```

You can see **three boundaries overlapping** — the app-layer Zod/size/MIME validation, the bucket restriction, and finally `storage.objects`'s RLS (`(storage.foldername(name))[1] = uid`) guaranteeing "can't place in another person's folder" on the DB side. Even if one layer is breached, the next stops it. This is defense in depth.

> **Beware path traversal**: assembling `name` directly from client input risks writing to another folder with `../`. Always **assemble the path on the server with `${user.id}/...` prepended**, and sanitize the filename. RLS's `foldername[1] = uid` is a further backstop, but don't break the principle of not trusting input.

---

## 5. Sharing patterns: team, public, time-limited

In a real app, "personal folders" alone aren't enough. Express representative sharing requirements in RLS.

```sql
-- チーム共有：teamsフォルダ配下は、そのチームのメンバーだけ読める
create policy "Team members can read team files"
on storage.objects for select to authenticated
using (
  bucket_id = 'team-files'
  and (storage.foldername(name))[1] = 'teams'
  and (storage.foldername(name))[2] in (
    select team_id::text from public.team_members
    where user_id = (select auth.uid())
  )
);
```

Make the path convention `teams/<team_id>/...` and judge whether the second folder name (`team_id`) is in "the set of teams you belong to." It's the same optimization form as [rewriting a JOIN into a set reference](/blog/supabase-rls-performance-optimization-select-wrap-index-guide). Don't forget to index `team_members(user_id, team_id)` either.

A "public but only the person can write" avatar is a public bucket + write RLS (`owner_id` or a uid folder). "Temporarily hand to an outsider" is a signed URL. **Designing by splitting into the four types of ownership, sharing, public, and temporary** keeps Storage authorization clear.

---

## 6. Verification: test that "outside your own folder is denied"

Like RLS, Storage policies become trustworthy in production only by testing **not just "allow" but "deny."** Since `storage.objects` is also an ordinary table, you can assert by impersonating an authenticated user.

```sql
-- pgTAP例：他人のuidフォルダのオブジェクトが見えないことを固定する
begin;
  select plan(1);
  set local role authenticated;
  set local request.jwt.claims = '{"sub":"11111111-1111-1111-1111-111111111111"}';
  -- 別ユーザー(2222...)のフォルダにある行が、このユーザーには0件であること
  select is(
    (select count(*) from storage.objects
     where bucket_id = 'user-files'
       and (storage.foldername(name))[1] = '22222222-2222-2222-2222-222222222222'
    )::int, 0,
    '他人のuidフォルダのファイルは見えない'
  );
  select * from finish();
rollback;
```

Follow [the article on protecting RLS with pgTAP](/blog/supabase-rls-testing-pgtap-policy-regression-guide) for CI-gating deny tests. In upload integration tests, always include one case that **"upload to another person's folder fails."**

---

## Conclusion: Storage is "another RLS"

- A Supabase Storage file is **a row in `storage.objects`.** Access control is **RLS** there, starting from the same **default deny** as the database.
- The standard is **"the leading folder = uid."** With `(storage.foldername(name))[1] = (select auth.jwt()->>'sub')`, confine each user to their own folder. You can also make it owner-based with `owner_id`.
- **A public bucket is readable by anyone who knows the URL.** Distribute confidential ones with a **private bucket + a minimal-expiry signed URL.**
- For **size, MIME, and content** that RLS doesn't look at, **defend in depth** with bucket restrictions + server-side validation. Don't trust the client-derived filename and Content-Type.
- Fix with a **test** that "another person's folder is denied," not just "allow."

Hardening even Storage with RLS lets you protect both data and files with the DB's structure, not "the app's goodwill." Next, the authorization of real-time communication — go to [protecting Supabase Realtime with RLS](/blog/supabase-realtime-rls-authorization-broadcast-presence-private-channel-guide).

### Primary sources (always confirm the latest)

- [Supabase: Storage Access Control](https://supabase.com/docs/guides/storage/security/access-control)
- [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
