「画像アップロード機能を付けたら、ユーザー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] で先頭フォルダ名が取れる——これが次章の鉄板パターンの心臓です。
この記事の脆弱性、あなたのアプリは大丈夫ですか?
Next.js × Supabase の認可・RLS を、専門家が監査します
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ケース入れます。
7. RLSだけでは塞がらないアップロードの穴
RLSが決めるのは「誰が、どのパスに、どの操作をしてよいか」までです。ファイル名に何が入るか、中身が本当は何か、どう配信されるか、URLがいつまで使えるかはRLSの外側にあり、ポリシーが完璧でもここから漏れます。実データを入れる前に、次の5点を個別に確認してください。
7-1. オブジェクト名をユーザーに決めさせない(上書き・すり替え)
ユーザーが決めた名前をそのままパスにすると、RLSの範囲内で「他人のファイルを差し替える」ことが起きます。典型は第5章のチーム共有フォルダです。Supabase公式(Storage Access Control)によれば、アップロードに要るのは INSERT だけですが、upsert による上書きを許すには SELECT と UPDATE も必要です。チームフォルダに UPDATE を開けると、メンバーの誰でも同じパスに上書きできる——たとえば同僚がアップロードした契約書PDFを、同じ名前の別ファイルに置き換えられます。
既定の挙動は安全側で、既存パスへのアップロードは 400 Asset Already Exists で失敗します(Standard Uploads)。これを崩さないことが基本です。
- パスの末尾はサーバーで生成する(第4章の
crypto.randomUUID())。ユーザーの元のファイル名は表示用にDBへ保存し、パスには使わない(OWASP ASVS 5.0 の 5.3.2 は、ファイルパスにユーザー提供の名前ではなく内部で生成した値を使うことを求めています) upsert: trueを使わない。差し替え機能が要るなら、共有フォルダでもUPDATEポリシーはowner_id = (select auth.uid())に絞る- ユーザー入力をパスの一部に使う場合は、
/と..を含む値を拒否する。公式のファイル名制限は使える文字の一覧で.も含まれるため、名前が「どのフォルダを指すか」の検証はアプリ側の責任です
7-2. Content-Type はクライアントの申告——自分のオリジンで HTML/SVG を配らない
バケットの allowed_mime_types は便利ですが、照合されるのはアップロードしてきた側が申告した型です。SDKの contentType オプションは「Content-Type ヘッダーの値」で、Supabase Storage のサーバー実装(supabase/storage の uploader.ts)でも、許可MIMEの照合対象はリクエストの Content-Type(マルチパートならフォームの contentType またはパートの型)です。バイト列の中身は見ていません。image/png と申告した HTML は、そのまま通り得ます。
これが最も効いてくるのは配信です。ユーザーがアップロードした HTML や SVG を、アプリと同じオリジン(Route Handler 経由のプロキシ、同一サイトのカスタムドメインなど)からブラウザに表示させると、ファイル内のスクリプトがあなたのサイトの権限で動きます(格納型XSS)。対策は次の3つです。
- 許可MIMEから
text/htmlとimage/svg+xmlを外す。画像は第4章のようにマジックナンバーで検証し、必要なら再エンコードする(ASVS 5.2.2) - 表示させる必要のないファイルは、ダウンロードとして返す。Supabase の公開URLは
?downloadクエリを付けるとブラウザに表示せずダウンロードさせます(Serving assets)。自前で配信するならContent-Disposition: attachmentを付ける(ASVS 3.2.1) - ユーザーのファイルを自アプリと同じオリジンから配信しない(ASVS 3.5.4 は、別のアプリケーションを別のホスト名で配信し同一オリジンポリシーの制限を活かすことを求めています)
7-3. 公開バケットはRLSの外——「一覧できる」ポリシーにも注意
第3章のとおり、公開バケットは読み取りと配信についてアクセス制御をバイパスし、URLを持つ人は誰でも読めます(Storage Buckets)。RLSが効くのはアップロード・削除・移動・コピーだけです。見落とされやすいのは次の2点です。
- 非公開→公開への切り替えは、既存ファイルも含めて全部を公開する。公式は「公開バケットにアップロードされたファイルはすべて公開でアクセスできる」と書いています。プロフィール画像用のバケットに後から本人確認書類を置く運用は、最初から破綻しています。用途ごとにバケットを分けてください
- 非公開バケットでも、広い
SELECTポリシーは「一覧」を許す。公式のポリシー例は、「誰でも avatars を読める」ポリシーにstorage.allow_any_operation()の絞り込みを付けないとバケットの中身を一覧できてしまう、と注意しています。ファイル名に顧客名や案件名が入っていれば、一覧だけで情報が漏れます
7-4. 署名URLは「持っている人が誰でも使える鍵」
署名URLは、期限内ならURLを持っている人が誰でも開けます。ログイン状態も本人確認も見ません。チャットへの転送、アクセスログ、エラー監視ツールに残ったURLは、そのまま閲覧権限です。
さらに重要な事実があります。Supabase公式(Serving assets)によれば、Storageの署名URLは Auth の JWT 署名鍵とは別の内部鍵で署名されています。そのため Auth の鍵をローテーションしても、レガシー鍵を無効化しても署名URLは影響を受けず、有効期限まで有効なままです。失効させたい場合は Supabase サポートへの連絡が必要とされています。
- 有効期限は用途に対して最小にする(第3章)。「念のため長め」は、失効できない鍵を長くばらまくことと同じです
- 署名URLをDBやメール本文に長期保存しない。必要になった時点で、認可を確認してから発行する
- 署名URLをアプリのログやエラー報告に出力しない
7-5. サイズ上限は「グローバル」と「バケット」の二段
サイズ制限には2つの層があります(File Limits)。Storage Settings のグローバル上限(Free プランは 50 MB まで、Pro・Team プランは 500 GB まで設定可能)と、それを超えられないバケットごとの上限(storage.buckets の file_size_limit)です。公式は、グローバル上限をアプリが受け付ける最大サイズにし、バケットごとに小さい上限を設定することを勧めています。
file_size_limit も allowed_mime_types も、未設定ならバケット単位の制限はかかりません。Dashboard で作ったバケットの設定を、本番前に一度 SQL で確認しておくと確実です。
-- 各バケットの公開範囲・サイズ上限・許可MIMEを一覧する(null は「バケット単位の制限なし」)
select id, public, file_size_limit, allowed_mime_types
from storage.buckets
order by id;
1ファイルのサイズ上限とは別に、1ユーザーが置けるファイル数・総容量にも上限を設けてください(ASVS 5.2.4)。上限がなければ、正規のユーザー1人でストレージ費用を膨らませられます。
この章の5点は、どれも「ポリシーは正しいのに漏れる」種類の問題で、RLSのテストだけでは見つかりません。実データを入れる前に第三者の目で確認したい場合は、Next.js × Supabase 専門のセキュリティ監査で、Storage のバケット設定と配信経路まで含めて確認しています。
まとめ: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を信用しない。
- 「許可」だけでなく「他人のフォルダが拒否される」をテストで固定する。
- RLSの外側にあるファイル名・申告されたMIME・配信オリジン・署名URLの寿命・サイズと件数の上限は、ポリシーとは別に塞ぐ(第7章)。
Storageまで含めてRLSで固めれば、データもファイルも「アプリの善意」ではなくDBの構造で守れます。次はリアルタイム通信の認可——Supabase RealtimeをRLSで守るへ。