# Supabase StorageをRLSで守る：バケット・ユーザー別フォルダ・署名URLでファイルのアクセス制御を設計する

> Supabase Storageのアクセス制御を、storage.objectsへのRLSポリシーで設計する実装ガイド。ポリシーが無ければアップロード不可（デフォルト拒否）、bucket_idとstorage.foldername(name)による『uidフォルダ＝自分のファイルだけ』パターン、公開/非公開バケットと署名URL、file_size_limit/MIME制限の多層防御、Next.jsからの安全なアップロードまで、公式準拠の実コードで解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Supabase, RLS, PostgreSQL, セキュリティ, Next.js
- URL: https://tomodahinata.com/blog/supabase-storage-rls-access-control-bucket-folder-policies-guide
- カテゴリ: データベース・RLS
- 総合ガイド: https://tomodahinata.com/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## 要点

- Supabase Storageのファイルはstorage.objectsテーブルの行であり、アクセス制御はそこへのRLSで行う。ポリシーが無ければアップロードも参照もできない（デフォルト拒否）
- 鉄板パターンは『先頭フォルダ名＝ユーザーのuid』。(storage.foldername(name))[1] = (select auth.jwt()->>'sub') で、各ユーザーを自分のuidフォルダに閉じ込める
- INSERT/SELECT/UPDATE/DELETEごとにbucket_id一致＋所有条件を書く。owner_idで『自分がアップロードした行だけ』を絞れる
- 公開バケットは誰でもURLで読める＝RLSは読み取りに効かない。機密ファイルは非公開バケット＋期限付き署名URL（createSignedUrl）で配る
- RLSはパスとowner_idしか見ない。ファイルサイズ・MIME型はバケット制限＋サーバー側検証で多層防御し、client由来のファイル名・Content-Typeを信用しない

---

「画像アップロード機能を付けたら、**ユーザーAが他人のファイルのURLを推測してダウンロードできた**」「非公開のつもりのバケットが、実は**全世界に公開**されていた」——Supabase Storageでファイルを扱い始めると、データベースのRLSとは別に、もう一段のアクセス制御設計が必要になります。

鍵になる事実はこれです。**Supabase Storageのファイルは、`storage.objects` というテーブルの「行」です。** だからアクセス制御も、テーブルと同じく**`storage.objects` への行レベルセキュリティ（RLS）**で行います。そしてSupabase公式は明言しています——**「RLSポリシーが無ければ、Storageはバケットへのアップロードを一切許可しない」**（[Supabase: Storage Access Control](https://supabase.com/docs/guides/storage/security/access-control)）。データベースと同じ**デフォルト拒否**から始まるのです。

この記事は、Storageのアクセス制御を**バケット設計・ユーザー別フォルダ・署名URL・多層防御**の4点で実装する方法を、公式準拠で解説します。題材は[リアルタイム試合記録アプリ](/case-studies/realtime-sports-scoring-app)（選手写真・試合データを扱い、全テーブルでRLSを運用）。内容は[Supabase公式](https://supabase.com/docs/guides/storage/security/access-control)・[PostgreSQL RLS公式](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)（2026年6月時点）に忠実です。

> **前提**：RLSの基礎（USING/WITH CHECK・ロール・デフォルト拒否）は[RLS入門](/blog/supabase-rls-getting-started-enable-first-policy-guide)で扱いました。本記事はそれをStorage（`storage.objects`）に適用する応用です。署名URLやバケット設定のAPI詳細は更新され得るため、本番前に公式で最新を確認してください。

---

## 1. メンタルモデル：ファイルは行、フォルダは仮想

最初に、Storageの正しい捉え方を固めます。

- **バケット（bucket）** = ファイルの入れ物。`public`（公開）か `private`（非公開）かを持つ。
- **オブジェクト（object）** = 1つのファイル。実体は `storage.objects` テーブルの1行で、`bucket_id`・`name`（パス）・`owner_id`（アップロードしたユーザー）などの列を持つ。
- **フォルダ** = 物理的な階層ではなく、`name` の中のスラッシュ区切り（`avatars/<uid>/photo.png` の `avatars` や `<uid>`）という**仮想的な概念**。

ここが効きます。フォルダが「`name` 列の中の文字列」だということは、**`name` のパスを述語にすれば、フォルダ単位のアクセス制御がRLSで書ける**ということです。Supabaseはそのためのヘルパ関数を用意しています。

| ヘルパ | 返すもの | 例（`name = 'avatars/uid-123/a.png'`） |
| --- | --- | --- |
| `storage.foldername(name)` | フォルダ名の配列 `text[]` | `{'avatars','uid-123'}` |
| `storage.filename(name)` | ファイル名 | `'a.png'` |

`(storage.foldername(name))[1]` で**先頭フォルダ名**が取れる——これが次章の鉄板パターンの心臓です。

---

## 2. 鉄板パターン：先頭フォルダ＝ユーザーのuid

最も再利用が効くStorageのRLS設計は、**「各ユーザーのファイルを、そのユーザーのuid名のフォルダに閉じ込める」**ことです。パスを `<uid>/ファイル名` という規約にし、RLSで「先頭フォルダ名が自分の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')
);
```

ポイントを3つ。

1. **`bucket_id` の一致を必ず入れる**。これが無いと、別バケットのファイルにまでポリシーが効いてしまう（または効かない）。バケットは「名前空間」なので、全ポリシーで束ねる。
2. **`auth.jwt()->>'sub'`** がユーザーのuid。`auth.uid()` でも同義です。`(select ...)` で包むのは[行ごとの再評価を防ぐ性能最適化](/blog/supabase-rls-performance-optimization-select-wrap-index-guide)。
3. **`owner_id` で絞る選択肢もある**。「アップロードした本人だけ」を表したいなら、パスではなく所有列で書けます。

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

`foldername` 方式は「パス規約でフォルダ単位に分ける」、`owner_id` 方式は「誰がアップロードしたかで分ける」。**共有が必要か（フォルダ単位）／純粋に個人所有か（owner_id）**で選びます。

---

## 3. 公開バケットと非公開バケット：RLSが効く範囲を間違えない

ここが最大の誤解ポイントです。**公開（public）バケットのファイルは、RLSに関係なく、URLを知っていれば誰でも読めます。**

| バケット種別 | 読み取り（GET） | 使いどころ |
| --- | --- | --- |
| **public** | 公開URLで**誰でも**読める（RLSは読みに効かない） | ロゴ・公開アバター等、秘匿不要なもの |
| **private** | 認証＋RLS、または**署名URL**が必要 | 個人文書・有料コンテンツ・機密画像 |

つまり——

> **機密ファイルを「公開バケット」に置いてはいけません。** 公開バケットは「推測されにくいURLだから安全」ではありません。URLが漏れた瞬間に誰でもアクセスできます。秘匿が要るなら**必ず非公開バケット**にします。

公開バケットでも、**書き込み（INSERT/UPDATE/DELETE）にはRLSが効きます**。「誰でも読めるが、書けるのは本人だけ」という公開アバターは、公開バケット＋書き込みRLSで実現します。

### 非公開ファイルを安全に配る：署名URL

非公開バケットのファイルをブラウザに見せるには、**期限付きの署名URL（signed URL）**を発行します。署名URLは「この特定のファイルを、この時間だけ、誰でも開ける」一時URLで、サーバー側で生成します。

```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 をクライアントに返す
```

**設計の勘所**：署名URLの有効期限は**用途に対して最小**にします（プレビューなら数十秒、ダウンロードリンクのメール配布なら数分〜数時間）。長すぎる期限は「実質公開」と同じです。また、署名URLを発行する前に**サーバー側で「このユーザーがこのファイルを見てよいか」を確認**してください——署名URLの発行は認可をバイパスし得るので、発行ロジック自体が認可境界になります。

---

## 4. 多層防御：RLSが見ないもの（サイズ・MIME）を別の層で守る

RLSは**パスと所有者**を見ますが、**ファイルの中身・サイズ・本当のMIME型**は見ません。ここを別の層で固めないと、「自分のフォルダに10GBの実行ファイルを置く」「`image/png` と偽った悪意あるファイルを上げる」を防げません。

### バケットレベルの制限

バケット作成時に**サイズ上限と許可MIME型**を設定できます。これが第一の関門です。

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

### サーバー側の検証（Content-Typeを信用しない）

バケット制限は便利ですが、**クライアントが申告するContent-Typeは詐称可能**です。本当に堅くするなら、アップロードを**Server Action経由**にして、サーバーでサイズと実際のバイト内容（マジックナンバー）を検証してから `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;
}
```

**3つの境界が重なっている**のが分かります——アプリ層のZod/サイズ/MIME検証、バケットの制限、そして最後に `storage.objects` のRLS（`(storage.foldername(name))[1] = uid`）が「他人のフォルダに置けない」をDB側で保証する。1層が破れても次が止める。これが多層防御です。

> **パストラバーサルに注意**：`name` をクライアントの入力からそのまま組み立てると、`../` で別フォルダに書かれる恐れがあります。パスは**必ずサーバーで `${user.id}/...` を前置**して組み立て、ファイル名はサニタイズ。RLSの `foldername[1] = uid` がさらに backstop になりますが、入力を信用しない原則は崩さないこと。

---

## 5. 共有パターン：チーム・公開・期限付き

実アプリでは「個人フォルダ」だけでは足りません。代表的な共有要件を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())
  )
);
```

パス規約を `teams/<team_id>/...` にし、2番目のフォルダ名（`team_id`）が「自分の所属チーム集合」に入るかで判定します。[JOINを集合参照に書き換える](/blog/supabase-rls-performance-optimization-select-wrap-index-guide)のと同じ最適化形です。`team_members(user_id, team_id)` に索引を張るのも忘れずに。

「公開だが本人だけ書ける」アバターは、公開バケット＋書き込みRLS（`owner_id` または uidフォルダ）。「一時的に外部へ渡す」は署名URL。**所有・共有・公開・一時、の4類型に分けて設計**すると、Storageの認可は見通しよく収まります。

---

## 6. 検証：自分のフォルダ外が「拒否される」ことをテストする

RLSと同じく、Storageのポリシーも**「許可」だけでなく「拒否」をテスト**してこそ本番で信頼できます。`storage.objects` も普通のテーブルなので、認証済みユーザーになりきってアサートできます。

```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;
```

拒否テストのCIゲート化は[pgTAPでRLSを守る記事](/blog/supabase-rls-testing-pgtap-policy-regression-guide)に従ってください。アップロードの統合テストでは、**「他人のフォルダへのuploadが失敗する」**ことを必ず1ケース入れます。

---

## まとめ：Storageは「もう一つのRLS」

- Supabase Storageのファイルは **`storage.objects` の行**。アクセス制御はそこへの**RLS**で、データベースと同じ**デフォルト拒否**から始まる。
- 鉄板は**「先頭フォルダ＝uid」**。`(storage.foldername(name))[1] = (select auth.jwt()->>'sub')` で各ユーザーを自分のフォルダに閉じ込める。`owner_id` で所有者基準にもできる。
- **公開バケットはURLを知れば誰でも読める**。機密は**非公開バケット＋期限最小の署名URL**で配る。
- RLSが見ない**サイズ・MIME・中身**は、バケット制限＋サーバー側検証で**多層防御**。client由来のファイル名・Content-Typeを信用しない。
- 「許可」だけでなく**「他人のフォルダが拒否される」をテスト**で固定する。

Storageまで含めてRLSで固めれば、データもファイルも「アプリの善意」ではなくDBの構造で守れます。次はリアルタイム通信の認可——[Supabase RealtimeをRLSで守る](/blog/supabase-realtime-rls-authorization-broadcast-presence-private-channel-guide)へ。

### 一次情報（必ず最新を確認してください）

- [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)
