Chuyển đến nội dung chính
友田 陽大
Databases & RLS
Supabase
RLS
PostgreSQL
セキュリティ
Next.js

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

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

Published
Reading time
10 min read
Author
友田 陽大
Chia sẻ

「画像アップロード機能を付けたら、ユーザー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_idname(パス)・owner_id(アップロードしたユーザー)などの列を持つ。
  • フォルダ = 物理的な階層ではなく、name の中のスラッシュ区切り(avatars/<uid>/photo.pngavatars<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つ。

  1. bucket_id の一致を必ず入れる。これが無いと、別バケットのファイルにまでポリシーが効いてしまう(または効かない)。バケットは「名前空間」なので、全ポリシーで束ねる。
  2. auth.jwt()->>'sub' がユーザーのuid。auth.uid() でも同義です。(select ...) で包むのは行ごとの再評価を防ぐ性能最適化
  3. 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で守るへ。

一次情報(必ず最新を確認してください)

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc