言語を切り替える
テーマを切り替える

Supabase Storage 実践:ファイルアップロード・権限制御・CDN 高速化

コンソールのエラーメッセージをじっと見つめる。ユーザーのアバターアップロード機能を公開して 30 分、さっそく報告が届きました。全員のアバターが同じ人物になってしまっている、と。

調べてみると、原因は Storage の RLS ポリシー設定にありました。そもそも設定していなかったのです。bucket は公開状態で、アップロードパスにユーザー分離もなく、誰のアップロード操作でも他人のファイルを上書きできてしまう。たった一つの権限設定の見落としが、危うく本番事故になりかけました。

Supabase Storage は使うのは簡単ですが、本当に使いこなす――権限制御、CDN 高速化、画像変換――となると、落とし穴が少なくありません。この記事では、踏んできた落とし穴と手探りで得た経験を、一度に整理してお伝えします。

一、まず動かす:標準ファイルアップロード

最初は一番基本的なところから。ファイルをアップロードします。

Bucket を作成する

Supabase コンソールを開き、左メニューの Storage を選んで “New bucket” をクリックします。bucket に名前を付けます。たとえばアバター用なら avatars、記事画像用なら posts といった具合です。ここで “Make this bucket public?” という選択肢が出てきますが、慌ててチェックを入れないでください。後ほど権限の章で詳しく説明します。

私の習慣はこうです。機密ファイルはプライベート bucket に、静的リソースは公開 bucket に置く。まずはプライベート bucket を作っておき、必要に応じて後から調整します。

SDK アップロードコード

@supabase/supabase-js をすでにインストールしている前提で、コードはとてもシンプルに書けます。

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  'https://your-project.supabase.co',
  'your-anon-key'
)

// ファイルをアップロード
async function uploadFile(file: File) {
  const filePath = `uploads/${Date.now()}-${file.name}`

  const { data, error } = await supabase.storage
    .from('avatars')  // bucket 名
    .upload(filePath, file, {
      cacheControl: '3600',  // 1 時間キャッシュ
      upsert: false  // ファイルが既存ならエラー、上書きしない
    })

  if (error) {
    console.error('アップロード失敗:', error.message)
    return null
  }

  return data.path  // ファイルパスを返す
}

正直に言うと、このコードは 10 回以上書いてきました。ポイントは filePath の設計です。なぜタイムスタンプの接頭辞を使うのか、どうやってユーザー分離をするのかは、後ほど説明します。

ファイルサイズの制限

公式ドキュメントでは、標準アップロードは最大 5GB のファイルに対応するとされています。ただ実際に使ってみると、6MB 未満のファイルは標準アップロードが一番快適です。6MB を超える場合は、TUS プロトコルのレジューム対応をおすすめします。

TUS とは何か。簡単に言えば、大きなファイルをアップロードするときにレジューム(中断からの再開)に対応する仕組みです。ネットワークが切れても、再接続すれば続きからアップロードでき、最初からやり直す必要がありません。動画や大きな画像といったファイルでは、体験が格段に良くなります。ユーザーが 90% まで進んだところで突然ネットワークが切れる場面を想像してみてください。TUS がなければ、再アップロードするしかありません。

TUS アップロードを有効にするには追加の設定が必要です。当面は使わないなら、標準アップロードでほとんどの場面に対応できます。

// TUS アップロードの例(大きなファイルにおすすめ)
const { data, error } = await supabase.storage
  .from('videos')
  .upload('large-video.mp4', file, {
    duplex: 'half',  // ストリーミングアップロードを有効化
    // TUS が自動でレジュームを処理する
  })

二、セキュリティ設定:RLS ポリシー徹底解説

冒頭の、深夜 3 時に踏んだ落とし穴に戻りましょう。権限を設定しておらず、ファイルが自由に上書きされてしまった件です。

Supabase の Storage はデータベースと同じく、基盤に PostgreSQL を使っています。だから権限制御も RLS(Row Level Security)の仕組みそのままです。bucket は一つのテーブルに相当し、各ファイルが 1 件のレコードになります。

Bucket の公開とプライベート

bucket を作成するとき、“Public bucket” にするか “Private bucket” にするかの選択肢があります。

Public bucket:誰でも読み取れて、認証は不要です。公開アバターやサイトのロゴといった静的リソースの保管に向いています。

Private bucket:アクセスには認証が必要です。ただしここに落とし穴があります。認証はあくまで入口の関門にすぎず、誰が読めて誰が書けるかは RLS ポリシー次第なのです。

私のおすすめはこうです。ファイルが本当に完全公開のものでない限り、デフォルトで Private bucket を作る。権限を設定してから公開するほうが、公開してから後で手当てするより、ずっと安全です。

RLS ポリシーの種類

Storage の Policy ページには、4 種類の操作が表示されます。

  • SELECT:ファイルの読み取り(ダウンロード、URL 取得)
  • INSERT:新規ファイルのアップロード
  • UPDATE:既存ファイルの更新・上書き
  • DELETE:ファイルの削除

それぞれの操作ごとにポリシーを個別に設定できます。最もよくある設定パターンはこちらです。

-- ユーザーは自分のファイルだけ操作できる
CREATE POLICY "Users manage own files"
ON storage.objects FOR ALL
USING (auth.uid()::text = (storage.foldername(name))[1]);

この SQL は少し複雑に見えるので、分解して説明します。

  • auth.uid() は現在ログイン中のユーザー ID を取得する
  • storage.foldername(name) はファイルパスの第一階層のディレクトリ名を抽出する
  • たとえばファイルパスが user123/avatar.jpg なら、第一階層のディレクトリは user123 になる

つまりこのポリシー全体のロジックは、ファイルパスの第一階層のディレクトリがユーザー ID と一致するときだけ、ユーザーがそのファイルを操作できる、というものです。これがユーザー分離の核心となる考え方です。

ユーザー分離の実装

具体的にどうするか。アップロード時にユーザー ID をパスの第一階層に置きます。

async function uploadAvatar(userId: string, file: File) {
  // パス設計:ユーザー ID/ファイル名
  const filePath = `${userId}/avatar-${Date.now()}.jpg`

  const { data, error } = await supabase.storage
    .from('avatars')
    .upload(filePath, file)

  return data?.path
}

こうすると、各ユーザーのファイルはそれぞれ自分の「フォルダ」の下に入ります。RLS ポリシーはユーザーが自分の ID で始まるパスしか操作できないようにするので、他人のファイルには触れません。

署名付きアクセス URL を生成する

Private bucket のファイルは、直接アクセスすると 404 になります。署名付きの URL を生成する必要があります。

// 一時アクセスリンクを生成(有効期限 1 時間)
const { data, error } = await supabase.storage
  .from('avatars')
  .createSignedUrl('user123/avatar.jpg', 3600)

console.log(data?.signedUrl)  // 署名付きの完全な URL

署名の有効期限は自分で決めます。長すぎると安全性が下がり、短すぎるとユーザー体験が悪くなる。一般には 1〜4 時間に設定するのが適切です。

ファイルを完全公開にしたいけれど bucket 設定は変えたくない、という場合は getPublicUrl が使えます。

const { data } = supabase.storage
  .from('public-assets')
  .getPublicUrl('logo.png')

// この URL は署名不要で、誰でもアクセスできる

ポリシー設定のよくある落とし穴

踏んできた落とし穴をいくつか挙げます。

  1. INSERT ポリシーの設定忘れ:ユーザーはログインできるのに、ファイルをアップロードできない。エラーは “new row violates row-level security policy” です。

  2. ポリシーが緩すぎる:たとえば USING (true) を使うと、すべてのファイルを誰でも操作できることになります。これは RLS を設定していないのと同じです。

  3. パス設計が不適切:ユーザー ID がパスの第一階層になければ、RLS の foldername 抽出が機能しません。以前これで失敗したことがあります。パスを uploads/user123/file.jpg と書いてしまい、抽出された結果は uploads になって、ポリシーの判定が誤ってしまったのです。

ポリシーを設定するときは、まずコンソールの SQL エディタでテストし、ロジックに問題がないことを確認してから本番環境に適用しましょう。

三、性能向上:Smart CDN と画像変換

ファイルをアップロードできて、権限も設定できました。次に考えるのは、どうやってファイルをもっと速く読み込ませるか、です。

Smart CDN の仕組み

Supabase の Smart CDN は、ふつうの CDN ではありません。ファイルのアクセス頻度に応じて、自動でキャッシュ戦略を決めます。人気ファイルはキャッシュ時間を長く、不人気ファイルはキャッシュ時間を短くする、という具合です。

公式ドキュメントによれば、キャッシュ失効の世界同期時間は最大 60 秒です。つまり東京でファイルを更新すると、60 秒以内にニューヨークのユーザーも最新版を見られます。従来の CDN が数分、ときには数時間かかるのと比べて、かなり速いです。

ただし Smart CDN は有料機能で、Pro Plan(月 $25)が必要です。Free Plan の場合でもファイルにはアクセスできますが、CDN 高速化はなく、Supabase のサーバーから直接読み取ることになります。

画像変換パラメータ

この機能は私のお気に入りです。画像の拡大縮小やトリミングを自分で処理する必要がなく、Supabase が URL にパラメータを足すだけで対応してくれます。

基本パラメータはこちらです。

?width=300&height=200  // サイズを指定
?resize=contain        // 比率を保ち、トリミングしない
?resize=cover          // サイズいっぱいに埋め、はみ出す部分をトリミング
?quality=80            // 画像品質(1〜100)
?format=webp           // WebP 形式に変換、サイズが小さくなる

組み合わせて使うとこうなります。

const baseUrl = supabase.storage
  .from('avatars')
  .getPublicUrl('user123/avatar.jpg').data.publicUrl

// サムネイルを生成
const thumbnailUrl = `${baseUrl}?width=100&height=100&resize=cover`

画像変換の制限はこちらです。

  • サイズ範囲:1〜2500 ピクセル
  • 元ファイルサイズ:25MB 以下
  • 対応フォーマット:JPEG、PNG、WebP、GIF、AVIF

制限を超えるとエラーになります。一度、30MB の元画像をアップロードして縮小しようとしたら、そのまま拒否されました。

課金:プロジェクトごとの無料枠

画像変換は変換回数で課金され、ストレージサイズでは課金されません。

プロジェクトごとに毎月最初の 100 枚の画像変換が無料です。超えると、1000 枚ごとに $5 課金されます。

正直なところ、個人プロジェクトや小規模チームなら 100 枚で基本的に足ります。私自身のブログプロジェクトでも、毎月変換するのはアバターと記事画像が数十枚程度です。Instagram のような画像 SNS アプリを作っているのでもない限り、この費用を心配する必要はあまりありません。

Next.js 連携:Image Loader

Next.js を使っているなら、Supabase の Image Loader を設定して、next/image に画像変換を自動処理させることができます。

// next.config.js
module.exports = {
  images: {
    loader: 'custom',
    loaderFile: './supabase-image-loader.js',
  }
}

そして loader ファイルを書きます。

// supabase-image-loader.js
export default function supabaseLoader({ src, width, quality }) {
  const params = new URLSearchParams()
  params.set('width', width.toString())
  params.set('quality', (quality || 75).toString())
  params.set('format', 'webp')

  return `${src}?${params.toString()}`
}

こうすると、Next.js で <Image src="..." width={300} /> を使うだけで、自動的に変換パラメータが付きます。

Pro Plan のハードル

ここまで説明した Smart CDN と画像変換は、どちらも Pro Plan が必要です。Free Plan のユーザーは、基本的なアップロード・ダウンロード機能しか使えません。

アップグレードすべきか。プロジェクトの要件次第です。アバターを数枚保管するだけなら、Free Plan で十分です。ただ、大量の画像を処理したり、性能チューニングをしたりするなら、Pro Plan の CDN 高速化と画像変換は確かに手間を大きく省いてくれます。自分で CDN を構築する必要も、画像処理サービスを書く必要もありません。

私自身の選択はこうです。プロジェクト公開前は Free Plan でテストし、トラフィックが安定してから Pro にアップグレードする。何しろ $25/月は小さな額ではありませんから。

四、実践事例:ブログプロジェクトの完全設定

理論をたくさん語るより、完全な事例を見るほうが早いでしょう。これは私のブログプロジェクトの Storage 設定で、ゼロから使えるところまでの内容です。

シナリオ:ユーザーアバター + 記事画像

bucket が 2 つ必要です。

  • avatars:ユーザーアバター。プライベート bucket で、ユーザーは自分のアバターしか操作できない
  • post-images:記事画像。プライベート bucket で、著者はアップロードでき、誰でも読み取れる(署名付き URL を使用)

Step 1:Bucket を作成する

コンソール操作はこうです。

  1. Storage > New bucket > 名前に avatars を入力、Private にチェック
  2. 同じ方法で post-images を作成

Step 2:RLS ポリシーを設定する

avatars bucket のポリシー:

-- 全ユーザーがアバターを読み取れる(公開読み取り)
CREATE POLICY "Anyone can view avatars"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');

-- ユーザーは自分のアバターのみアップロード・更新できる
CREATE POLICY "Users manage own avatar"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]);

-- ユーザーは自分のアバターのみ削除できる
CREATE POLICY "Users delete own avatar"
ON storage.objects FOR DELETE
USING (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]);

post-images bucket のポリシー:

-- 著者は記事画像をアップロードできる(著者に author ロールがある前提)
CREATE POLICY "Authors can upload post images"
ON storage.objects FOR INSERT
WITH CHECK (
  bucket_id = 'post-images'
  AND auth.jwt() ->> 'role' = 'author'
);

-- 全員が記事画像を読み取れる
CREATE POLICY "Public read post images"
ON storage.objects FOR SELECT
USING (bucket_id = 'post-images');

Step 3:フロントエンドのアップロードコード

アバターアップロードコンポーネント:

async function handleAvatarUpload(file: File) {
  const user = await supabase.auth.getUser()
  if (!user.data.user) return alert('先にログインしてください')

  // パス:ユーザー ID/avatar.jpg(固定ファイル名、毎回アップロードで旧アバターを上書き)
  const filePath = `${user.data.user.id}/avatar.jpg`

  const { error } = await supabase.storage
    .from('avatars')
    .upload(filePath, file, { upsert: true })

  if (!error) {
    // 公開 URL を取得(SELECT ポリシーが全員の読み取りを許可しているため)
    const url = supabase.storage.from('avatars').getPublicUrl(filePath)
    setUserAvatar(url.data.publicUrl)
  }
}

記事画像アップロード:

async function handlePostImageUpload(file: File) {
  const filePath = `posts/${Date.now()}-${file.name}`

  const { data, error } = await supabase.storage
    .from('post-images')
    .upload(filePath, file)

  if (!error) {
    // 署名付き URL を生成、有効期限 24 時間
    const { data: urlData } = await supabase.storage
      .from('post-images')
      .createSignedUrl(filePath, 86400)

    insertImageToEditor(urlData?.signedUrl)
  }
}

Step 4:テスト検証

公開前に、いくつかの重要なポイントを検証します。

  1. 未ログインユーザーは記事画像を見られるか?(見られるはず。SELECT ポリシーが許可)
  2. 一般ユーザーは記事画像をアップロードできるか?(できないはず。author ロールのみ)
  3. ユーザー A はユーザー B のアバターを上書きできるか?(できないはず。パス分離)

それぞれを一通りテストして、ポリシー設定に誤りがないことを確認します。深夜 3 時の教訓を、二度と味わいたくはありません。

まとめ

Supabase Storage の核心は 3 つ。アップロード、権限、高速化です。

アップロードは一番簡単で、数行のコードで済みます。ただ権限設定にはもう少し気を配る必要があります。RLS ポリシーは一度設定して終わりではなく、業務シナリオに合わせて繰り返しテストすべきものです。CDN と画像変換は付加価値で、Pro Plan でないと使えませんが、確かに開発時間を大きく省けます。

私の経験はこうです。まず基本的なアップロードと権限を仕上げ、セキュリティ事故を起こさないようにする。後で性能要件が出てきたら CDN を足し、画像処理要件が出てきたら変換を足す。一歩ずつ進めて、欲張りすぎないことです。

Supabase Storage を使っているなら、あなたの落とし穴体験もぜひ共有してください。あの深夜 3 時の教訓は、私だけが遭遇したものではないはずですから。

Supabase Storage 完全設定フロー

Bucket の作成から権限設定、CDN 高速化までの一通りの実践

⏱️ 目安時間: 30 分

  1. 1

    ステップ1: Bucket を作成する

    Supabase コンソールでプライベート Bucket を作成します:

    • Storage > New bucket を開く
    • 名前を入力(例:avatars)
    • Private にチェック(デフォルトはプライベート推奨)
    • Create bucket をクリック
  2. 2

    ステップ2: RLS ポリシーを設定する

    Bucket に Row Level Security を設定します:

    • Storage > Bucket を選択 > Policies を開く
    • New Policy をクリック
    • 操作の種類を選ぶ(SELECT/INSERT/UPDATE/DELETE)
    • ポリシールールを記述(ユーザー分離など)
    • テスト後に本番環境へ適用
  3. 3

    ステップ3: ファイルをアップロードする

    SDK でファイルをアップロードします:

    • パス構造を設計(userId/filename など)
    • storage.from().upload() を呼び出す
    • cacheControl と upsert パラメータを設定
    • アップロードエラーと戻り値のパスを処理
  4. 4

    ステップ4: CDN と画像変換を設定する(任意)

    Pro Plan へアップグレードすると高度な機能が使えます:

    • Smart CDN が人気ファイルを自動キャッシュ
    • 画像変換 URL パラメータ(width/height/format)
    • Next.js Image Loader 連携
    • 無料枠の監視(100 枚/月)

FAQ

Public bucket と Private bucket の違いは何ですか?
Public bucket は誰でも読み取れるので、ロゴや公開アバターなど公開静的リソースに向いています。Private bucket は認証しないとアクセスできませんが、具体的な権限は RLS ポリシーで制御するため、より安全です。
RLS ポリシーでユーザー分離を実現するには?
核心となる考え方は、ユーザー ID をパスの第一階層に置くことです:

• アップロードパスを userId/filename に設計する
• ポリシーで auth.uid()::text = (storage.foldername(name))[1] を使う
• こうすると、ユーザーは自分の ID で始まるパスしか操作できない
Private bucket のファイルを外部に共有するには?
createSignedUrl() で署名付きの一時アクセス URL を生成します。有効期限を設定でき、長すぎると安全性が下がり、短すぎると使い勝手が悪いので、通常は 1〜4 時間に設定します。
ファイルアップロードにはどんな制限がありますか?
標準アップロードは最大 5GB です。6MB 未満なら標準アップロードが最も快適で、6MB を超える場合は TUS プロトコルのレジューム対応がおすすめです。ネットワークが途切れても続きからアップロードできます。
画像変換はどんなパラメータと制限に対応していますか?
対応パラメータ:

• width/height:サイズ(範囲 1〜2500 ピクセル)
• resize:contain(比率を保つ)または cover(トリミングして埋める)
• quality:品質(1〜100)
• format:webp/jpeg/png/gif/avif

制限:元ファイルは 25MB 以下
Smart CDN と画像変換は有料ですか?
はい、Pro Plan($25/月)が必要です。Free Plan では基本的なアップロード・ダウンロード機能のみ使えます。画像変換には無料枠があり、プロジェクトごとに毎月 100 枚まで、超えると 1000 枚ごとに $5 課金されます。

5分で読めます · 公開日: 2026年4月9日 · 更新日: 2026年6月1日

関連記事

コメント

GitHubアカウントでログインしてコメントできます