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

Supabase Storage 実践:ファイルアップロード、CDN、アクセス制御

先週、ある読者から質問されました。「ユーザーがアバターをアップロードする機能、S3 と Cloudflare R2 のどちらがいい?」

少し考え込みました。この問題は、2 つのプロジェクトでどちらも痛い目に遭ったことがあります。S3 の権限ポリシーを設定するときは頭が爆発しそうでしたし、R2 は安いものの、認証のしくみを自前で用意する必要がありました。その後、プロジェクトを Supabase Storage に移行しました。「銀の弾丸」だからではありません。すでに Supabase の Auth とデータベースを使っているなら、Storage を組み合わせたときの相性が本当にいいからです。

この記事では、Supabase Storage の中核となるしくみ、3 つのアクセス制御モード、大きいファイルをアップロードする際の落とし穴、CDN の最適化テクニック、そして R2/S3 とのコスト比較まで、すべて余すところなく解説します。コードはすべてそのまま動かせるものです。


一、Supabase Storage の中核アーキテクチャ

まずはっきりさせておきたいことがあります。Supabase Storage の基盤は AWS S3 です。

ただし、その上にごく薄いラッパーをかぶせています。薄いので、JavaScript SDK で直接操作でき、AWS の複雑な認証情報体系や IAM ポリシーを気にする必要がありません。

Auth と自動的に結びつく

これが私の一番好きな点です。Supabase で bucket を 1 つ作り、あとは supabase.auth.getUser() で取得した JWT token を使って、誰がアップロードでき、誰がダウンロードできるかを制御します。権限システムを別途構築する必要はありません。

// アップロード時に自動でユーザー情報を付与
const { data, error } = await supabase.storage
  .from('avatars')
  .upload('user-123/profile.jpg', file)

内部では、設定した RLS(Row Level Security)ポリシーをチェックします。設定方法は後ほど詳しく説明します。

全世界 CDN を自動で利用

アップロードしたファイルは、自動的に Cloudflare CDN で配信されます。CloudFront や Cloudflare Workers を自分で設定する必要はありません。

ブラウザの開発者ツールを開き、レスポンスヘッダーの cf-cache-status を見てみましょう:

cf-cache-status: HIT

HIT はキャッシュに命中したこと、MISS は命中しなかったことを意味します。Public bucket の命中率は、ふつう Private bucket よりかなり高くなります。Private bucket のほうがキャッシュ戦略が厳しいためです。

Smart CDN:キャッシュの「自動失効」のしくみ

これは Supabase の見どころの 1 つです。従来の CDN では、ファイルを更新したあと手動でキャッシュを purge するか、TTL の期限切れを待つ必要がありました。Supabase の Smart CDN は、ファイルの metadata を自動でエッジノードへ同期します。ファイルを更新すると、最大 60 秒で世界中に反映されます。

ただ、まだ喜ぶのは早いです。リアルタイム性が求められる場面では、60 秒はけっこう長く感じます。即時反映が必要なら、後述する cacheNonce パラメータを使う必要があります。


二、3 つのアクセス制御モードの比較

ここは多くの人が混乱しやすいところです。Public bucket、Private bucket、Signed URL という 3 つのモードには、それぞれ適した場面があります。選び方を間違えると、キャッシュ命中率がひどく低くなるか、セキュリティに問題が出ます。

3 種類
アクセス制御モード
Public、Private、Signed URL にそれぞれ適した場面がある

2.1 Public Bucket:公開リソースの第一候補

ファイルがもともと全員に見せるもの(サイトの logo、ブログの挿絵、公開ドキュメントなど)なら、Public bucket をそのまま使いましょう。

メリット

  • URL が最もシンプル:https://xxx.supabase.co/storage/v1/object/public/bucket-name/file.jpg
  • キャッシュ命中率が最も高く、CDN が直接応答し、Auth 検証を通らない
  • コードが最もシンプルで、1 行で済む
// Public URL を取得
const { data } = supabase.storage
  .from('public-images')
  .getPublicUrl('hero-banner.jpg')

console.log(data.publicUrl)
// https://xxx.supabase.co/storage/v1/object/public/public-images/hero-banner.jpg

適した場面

  • ユーザーのアバター(公開表示するもの)
  • ブログの挿絵
  • サイトの静的リソース
  • 公開ドキュメント

2.2 Private Bucket + Signed URL:非公開ファイルの標準解

公開できないファイルもあります。ユーザーがアップロードした契約書、会員専用コンテンツ、機密文書などです。このときは Private bucket を使い、有効期限付きの Signed URL を生成します。

// 1 時間有効なアクセスリンクを生成
const { data, error } = await supabase.storage
  .from('private-docs')
  .createSignedUrl('contracts/user-123.pdf', 3600) // 3600 秒 = 1 時間

console.log(data.signedUrl)
// https://xxx.supabase.co/storage/v1/object/sign/private-docs/contracts/user-123.pdf?token=xxx

注意:Signed URL は生成のたびに異なります。これがキャッシュ命中率に影響します。新しい Signed URL を頻繁に生成すると、CDN はずっと MISS になります。

最適化テクニック:同じユーザーが短時間に同じファイルへ何度もアクセスする場合は、Signed URL をフロントエンドや Redis にキャッシュし、毎回生成し直さないようにしましょう。

2.3 RLS ポリシー:きめ細かな権限制御

これは最も強力でありながら、最も見落とされやすい部分です。storage.objects テーブル上に RLS ポリシーを定義すると、誰がどのファイルを操作できるかを正確に制御できます。

シーン 1:ユーザーは自分のフォルダにのみアップロードできる

-- storage.objects テーブルにポリシーを作成
CREATE POLICY "Users can upload to own folder"
ON storage.objects FOR INSERT
WITH CHECK (
  bucket_id = 'avatars' 
  AND auth.uid()::text = (storage.foldername(name))[1]
);

-- 解説:name はファイルの完全なパス。例 'user-123/avatar.jpg'
-- storage.foldername(name)[1] は最初のフォルダ名、つまり 'user-123' を取得
-- auth.uid() は現在ログイン中のユーザー ID
-- 両者が一致したときのみアップロードを許可

シーン 2:管理者はすべてのファイルにアクセスできる

CREATE POLICY "Admins can access all"
ON storage.objects FOR ALL
USING (
  auth.jwt() ->> 'role' = 'admin'
);

シーン 3:会員だけが特定のコンテンツをダウンロードできる

CREATE POLICY "Members can download premium content"
ON storage.objects FOR SELECT
USING (
  bucket_id = 'premium-content'
  AND EXISTS (
    SELECT 1 FROM user_subscriptions
    WHERE user_id = auth.uid()
    AND status = 'active'
  )
);

この 3 つのポリシーを組み合わせれば、ほとんどの業務シーンをカバーできます。


三、ファイルアップロードの実践

いよいよ手を動かすパートです。

3.1 標準アップロード:小さいファイルを手早く

5MB 未満のファイルなら、upload メソッドをそのまま使えば大丈夫です。

// React のアップロードコンポーネント
import { useState } from 'react'
import { supabase } from './supabase-client'

export function AvatarUpload() {
  const [uploading, setUploading] = useState(false)
  const [avatarUrl, setAvatarUrl] = useState<string | null>(null)

  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return

    setUploading(true)
    
    const fileExt = file.name.split('.').pop()
    const fileName = `${Date.now()}.${fileExt}`
    const filePath = `avatars/${fileName}`

    const { error } = await supabase.storage
      .from('public-images')
      .upload(filePath, file, {
        cacheControl: '3600', // ブラウザキャッシュ 1 時間
        upsert: false // 既存ファイルを上書きしない
      })

    if (error) {
      alert('アップロード失敗:' + error.message)
    } else {
      const { data } = supabase.storage
        .from('public-images')
        .getPublicUrl(filePath)
      setAvatarUrl(data.publicUrl)
    }

    setUploading(false)
  }

  return (
    <div>
      <input 
        type="file" 
        accept="image/*" 
        onChange={handleUpload}
        disabled={uploading}
      />
      {avatarUrl && <img src={avatarUrl} alt="avatar" />}
      {uploading && <p>アップロード中...</p>}
    </div>
  )
}

いくつかのポイント:

  • cacheControl はブラウザのキャッシュ時間を設定するもので、CDN キャッシュとは別物
  • upsert: false で不意の上書きを防止。上書きしたいなら true に変える
  • ファイル名はタイムスタンプで重複を避ける。UUID でもよい

3.2 TUS 分割アップロード:大きいファイルの安定解

5MB を超えるファイルや、ネットワークが不安定な環境では、TUS 分割アップロードを使います。

重要な制限chunkSize は必ず 6MB です。変更できません。これは Supabase のハードコードされた制限です。

有効期限:アップロード URL は 24 時間有効です。タイムアウトしたら生成し直します。

まず依存をインストールします:

npm install tus-js-client uppy @uppy/core @uppy/dashboard @uppy/tus

完全なコード:

import Uppy from '@uppy/core'
import { Dashboard } from '@uppy/react'
import Tus from '@uppy/tus'
import { supabase } from './supabase-client'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'

export function LargeFileUploader() {
  const uppy = new Uppy({
    restrictions: {
      maxFileSize: 100 * 1024 * 1024, // 100MB
      allowedFileTypes: ['video/*', 'image/*']
    }
  })

  // Supabase の session token を取得
  const getSession = async () => {
    const { data: { session } } = await supabase.auth.getSession()
    return session?.access_token || ''
  }

  uppy.use(Tus, {
    endpoint: 'https://xxx.supabase.co/storage/v1/upload/resumable',
    chunkSize: 6 * 1024 * 1024, // 必ず 6MB
    async onBeforeRequest(req) {
      const token = await getSession()
      req.setHeader('Authorization', `Bearer ${token}`)
    },
    onAfterResponse(req, res) {
      // アップロード完了後にファイルパスを取得
      const location = res.getHeader('Location')
      console.log('File uploaded to:', location)
    }
  })

  return (
    <div style={{ maxWidth: '600px', margin: '0 auto' }}>
      <Dashboard uppy={uppy} />
    </div>
  )
}

トラブルシューティング:分割アップロードが 6MB のところで止まって動かないときは、次を確認します:

  1. chunkSize が 6MB か(正確に)
  2. Token が期限切れになっていないか(24 時間有効)
  3. RLS ポリシーが INSERT を許可しているか(GitHub Issue #563 を参照)

3.3 Presigned Upload URL:サードパーティへのアップロード許可

ユーザーに直接アップロードさせたいけれど、service_role key を露出させたくない場面があります。そんなときは createSignedUploadUrl で署名付きのアップロード先を生成します。

// サーバー側でアップロード URL を生成
const { data, error } = await supabase.storage
  .from('user-uploads')
  .createSignedUploadUrl('documents/report.pdf')

// data.signedUrl をフロントエンドに渡して直接アップロードできる
// フロントエンドは service_role key を知る必要がない

四、CDN と画像最適化

4.1 Smart CDN のしくみを詳しく

前述のとおり、Smart CDN はファイル更新後にキャッシュを自動失効します。ただ、60 秒の伝播遅延はやはり待ち遠しく感じることがあります。

ベストプラクティス

  1. 頻繁に更新するファイルは、新しいパスにアップロードする
// こうしない:毎回同じファイルを更新
await storage.from('images').upload('logo.png', file, { upsert: true })

// こうする:毎回新しいファイル名を生成
const version = Date.now()
await storage.from('images').upload(`logo-${version}.png`, file)
  1. cacheNonce でキャッシュを強制的にバイパスする
const { data } = supabase.storage
  .from('images')
  .getPublicUrl('logo.png', {
    cacheNonce: Date.now().toString() // リクエストごとに異なる
  })
  1. Signed URL の再利用

同じユーザーが同じファイルにアクセスするなら、Signed URL を保存しておき、毎回生成し直さないようにします。

4.2 画像変換と自動最適化

Supabase はリアルタイムの画像変換に対応しています。幅、高さ、品質、フォーマットを調整できます。

制限

  • 幅・高さ:1〜2500px
  • ファイルサイズ:25MB 以下
  • 解像度:50MP 以下
// サムネイルを取得
const { data } = supabase.storage
  .from('images')
  .getPublicUrl('hero.jpg', {
    transform: {
      width: 300,
      height: 200,
      resize: 'cover', // または 'contain'、'fill'
      quality: 80,
      format: 'webp' // 自動で WebP に変換
    }
  })

Next.js の画像 Loader 連携

// next.config.js
module.exports = {
  images: {
    loader: 'custom',
    loaderFile: './lib/supabase-image-loader.js'
  }
}
// lib/supabase-image-loader.js
export default function supabaseLoader({ src, width, quality }) {
  const url = new URL(src)
  url.searchParams.set('width', width.toString())
  url.searchParams.set('quality', (quality || 75).toString())
  url.searchParams.set('format', 'webp')
  return url.toString()
}

料金:画像変換は origin images 1000 枚あたり $5 です。画像が大量に変換される場合(レスポンシブ画像で複数サイズを生成するなど)は、この費用をきちんと見積もっておきましょう。


五、コスト比較と選定のヒント

ここは多くの人が気にする部分です。主要な選択肢を並べて比較します。

5.1 価格比較表

サービスストレージ料金送信費用無料枠特徴
Supabase StorageS3 価格に準拠CDN は別料金Pro プランに含むAuth 連携、RLS
Cloudflare R2$0.015/GBゼロ10GB + 1M ops送信費用ゼロ
AWS S3$0.023/GB$0.09/GB5GB/12 か月最強のエコシステム
DigitalOcean Spaces$5/250GB込みなし固定料金
$0
Cloudflare R2 の送信費用

5.2 選定の判断

ダウンロード量が多い場面 → R2

ファイルが頻繁にダウンロードされる場合(画像共有サイト、動画ホスティングなど)、R2 の送信費用ゼロは大きな節約になります。S3 の送信費用は 1GB あたり 10 セント近く、トラフィックが増えると懐が痛みます。

Auth 連携が必要 → Supabase Storage

すでに Supabase の Auth とデータベースを使っているなら、Storage の連携はスムーズです。ユーザー権限の制御も RLS ポリシーもそのまま再利用できます。

AWS エコシステムのヘビーユーザー → S3

Lambda、CloudFront、S3 Select、S3 Glacier──アーキテクチャがすでに AWS に結びついているなら、移行コストが節約できる金額を上回ることもあります。

予算固定、トラフィックが予測可能 → DigitalOcean Spaces

毎月固定料金なので、使用量ベースの課金を気にしたくない小規模プロジェクトに向きます。

5.3 コスト最適化のヒント

  1. ライフサイクルポリシー:古いファイルを自動で Glacier へアーカイブ
  2. 画像圧縮:アップロード前に圧縮するか、Supabase の画像変換を使う
  3. Public bucket でキャッシュ命中率を上げる:公開できるものはできるだけ公開する
  4. Signed URL の再利用:再生成による無駄なキャッシュ MISS を減らす

六、よくある問題のトラブルシューティング

6.1 ファイルを更新しても古いバージョンのまま

原因:Smart CDN の 60 秒の伝播遅延。

解決策

  • 60 秒待つ
  • 新しいパスにアップロードする
  • cacheNonce でキャッシュをバイパスする

6.2 分割アップロードが 6MB で止まる

原因chunkSize の設定ミス。

解決策chunkSize: 6 * 1024 * 1024 であること、バイト単位まで正確であることを確認します。

// 誤り:5MB と書いている
chunkSize: 5 * 1024 * 1024 // 止まってしまう

// 正しい:必ず 6MB
chunkSize: 6 * 1024 * 1024

6.3 アップロードで 403 Forbidden が返る

原因:RLS ポリシーが正しく設定されていない。

確認手順

  1. bucket が Public か Private かを確認
  2. storage.objects テーブル上の RLS ポリシーを確認
  3. ポリシーが INSERT 操作を許可しているか確認
-- 既存のポリシーを確認
SELECT * FROM pg_policies WHERE tablename = 'objects';

-- アップロードを許可するポリシーを追加
CREATE POLICY "Allow upload"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'your-bucket');

6.4 Signed URL にアクセスできない

原因:URL の期限切れ、または token が無効。

解決策

  • 有効期限が妥当か確認する
  • token が途中で切れていないか確認する
  • テスト時はまず長めの有効期限の URL(24 時間など)を生成する

まとめ

要点を整理しましょう:

  • Public bucket は公開リソース向きで、キャッシュ命中率が最も高い
  • Private bucket + Signed URL は非公開ファイル向き。キャッシュ戦略に注意
  • RLS ポリシー はきめ細かな権限制御を提供する。この機能を見落とさないこと
  • TUS 分割アップロード で大きいファイルを扱う。chunkSize は必ず 6MB
  • Smart CDN はキャッシュを自動失効するが、60 秒の遅延がある
  • 選定:ダウンロード量が多いなら R2、Auth 連携なら Supabase Storage、AWS エコシステムなら S3

すでに Supabase のデータベースと認証を使っているなら、Storage は自然な選択です。ただ、必要なのが純粋なオブジェクトストレージで、しかもトラフィックが多く予算に敏感なら、R2 の送信費用ゼロはたしかに魅力的です。

Supabase Storage について、ほかに質問や踏んだ落とし穴はありますか? ぜひコメント欄で交流しましょう。


参考資料

Supabase Storage のファイルアップロード完全フロー

bucket の作成から RLS ポリシーの設定まで、安全で制御可能なファイルアップロードを実現する

⏱️ 目安時間: 30 分

  1. 1

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

    Supabase Dashboard で bucket を作成します:

    • Storage ページに移動し、"Create a new bucket" をクリック
    • bucket 名を入力(avatars、documents など)
    • Public または Private モードを選択
    • Public bucket のファイルは直接アクセス可能、Private は署名付き URL が必要
  2. 2

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

    storage.objects テーブル上でアクセス制御を定義します:

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

    • bucket_id で対象 bucket を一致させる
    • auth.uid() で現在のユーザー ID を取得
    • storage.foldername() でファイルパスを解析
  3. 3

    ステップ3: 標準アップロードを実装する

    upload メソッドで小さいファイル(5MB 未満)をアップロードします:

    ```typescript
    const { error } = await supabase.storage
    .from('bucket-name')
    .upload('path/file.jpg', file, {
    cacheControl: '3600',
    upsert: false
    });
    ```

    • cacheControl でブラウザのキャッシュ時間を設定
    • upsert: false で既存ファイルの上書きを防止
  4. 4

    ステップ4: TUS 分割アップロードを設定する

    大きいファイル(5MB 超)は TUS プロトコルで扱います:

    • 依存をインストール:npm install @uppy/tus tus-js-client
    • chunkSize: 6 * 1024 * 1024 を設定(必ず 6MB)
    • Authorization header で JWT token を渡すよう設定
    • アップロード URL の有効期限は 24 時間
  5. 5

    ステップ5: CDN キャッシュを最適化する

    キャッシュ命中率を高める要点:

    • Public bucket のキャッシュ命中率が最も高い
    • 頻繁に更新するファイルは上書きではなく新しいパスを使う
    • cacheNonce でキャッシュを強制的に更新
    • Signed URL のキャッシュを再利用し、毎回の生成を避ける

FAQ

Supabase Storage の chunkSize はなぜ 6MB でなければならないの?
これは Supabase サーバー側のハードコードされた制限です。他の値(5MB など)に設定すると、アップロードが止まったまま動かなくなります。コード内で正確に chunkSize: 6 * 1024 * 1024 と設定してください。
Public bucket と Private bucket はどう選べばいい?
ファイルのアクセス権限に応じて選びます:

• Public bucket:サイトの静的リソース、公開画像、ブログの挿絵など、誰でもアクセスできるもの
• Private bucket:ユーザーの非公開ファイル、会員向けコンテンツ、機密文書など、Signed URL や RLS ポリシーでアクセスを制御する必要があるもの
ファイルを更新したのに、なぜ古いバージョンのまま?
Smart CDN のキャッシュ失効は、世界各地のノードへ伝播するのに最大 60 秒かかります。解決策は 3 つです:60 秒待つ、新しいパスにアップロードする(おすすめ)、cacheNonce パラメータでキャッシュを強制的にバイパスする。
Supabase Storage と Cloudflare R2 はどちらが安い?
利用シーンによります:

• ダウンロード量が多い:R2 は送信(egress)費用ゼロでお得(S3 の送信費用は $0.09/GB)
• Auth 連携が必要:Supabase Storage が便利(RLS ポリシーをそのまま再利用できる)
• すでに Supabase を使用:Storage が自然な選択
• 純粋なオブジェクトストレージ:R2 のコストが低い
RLS ポリシーで、ユーザーが自分のファイルだけにアクセスできるようにするには?
storage.foldername() でファイルパスを解析し、auth.uid() でユーザー ID を一致させます:

```sql
CREATE POLICY "Users own files"
ON storage.objects FOR ALL
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
```

こうすると、ユーザーはパスの先頭が自分のものであるファイルだけを操作できます。
Signed URL のキャッシュ命中率が低いときは?
Signed URL は生成のたびに異なるため、CDN キャッシュが MISS になります。最適化策:Signed URL をフロントエンドや Redis にキャッシュし、同じユーザーが短時間で再利用する。あるいは RLS ポリシーでアクセスを制御し、ファイルを Public bucket 経由にする。

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

関連記事

コメント

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