「画像アップロード機能を付けたら、ユーザーAが他人のファイルのURLを推測してダウンロードできた」「非公開のつもりのバケットが、実は全世界に公開されていた」——Supabase Storageでファイルを扱い始めると、データベースのRLSとは別に、もう一段のアクセス制御設計が必要になります。
鍵になる事実はこれです。Supabase Storageのファイルは、storage.objects というテーブルの「行」です。 だからアクセス制御も、テーブルと同じく**storage.objects への行レベルセキュリティ(RLS)で行います。そしてSupabase公式は明言しています——「RLSポリシーが無ければ、Storageはバケットへのアップロードを一切許可しない」(Supabase: Storage Access Control)。データベースと同じデフォルト拒否**から始まるのです。
この記事は、Storageのアクセス制御をバケット設計・ユーザー別フォルダ・署名URL・多層防御の4点で実装する方法を、公式準拠で解説します。題材はリアルタイム試合記録アプリ(選手写真・試合データを扱い、全テーブルでRLSを運用)。内容はSupabase公式・PostgreSQL RLS公式(2026年6月時点)に忠実です。
前提:RLSの基礎(USING/WITH CHECK・ロール・デフォルト拒否)はRLS入門で扱いました。本記事はそれを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と一致する行だけ」を許可します。
-- アップロード:自分の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つ。
bucket_idの一致を必ず入れる。これが無いと、別バケットのファイルにまでポリシーが効いてしまう(または効かない)。バケットは「名前空間」なので、全ポリシーで束ねる。auth.jwt()->>'sub'がユーザーのuid。auth.uid()でも同義です。(select ...)で包むのは行ごとの再評価を防ぐ性能最適化。owner_idで絞る選択肢もある。「アップロードした本人だけ」を表したいなら、パスではなく所有列で書けます。
-- 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で、サーバー側で生成します。
// 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型を設定できます。これが第一の関門です。
// サーバー側(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 に渡します。
// 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で表します。
-- チーム共有: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を集合参照に書き換えるのと同じ最適化形です。team_members(user_id, team_id) に索引を張るのも忘れずに。
「公開だが本人だけ書ける」アバターは、公開バケット+書き込みRLS(owner_id または uidフォルダ)。「一時的に外部へ渡す」は署名URL。所有・共有・公開・一時、の4類型に分けて設計すると、Storageの認可は見通しよく収まります。
6. 検証:自分のフォルダ外が「拒否される」ことをテストする
RLSと同じく、Storageのポリシーも**「許可」だけでなく「拒否」をテスト**してこそ本番で信頼できます。storage.objects も普通のテーブルなので、認証済みユーザーになりきってアサートできます。
-- 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を守る記事に従ってください。アップロードの統合テストでは、**「他人のフォルダへの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で守るへ。