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 つのモードには、それぞれ適した場面があります。選び方を間違えると、キャッシュ命中率がひどく低くなるか、セキュリティに問題が出ます。
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 のところで止まって動かないときは、次を確認します:
chunkSizeが 6MB か(正確に)- Token が期限切れになっていないか(24 時間有効)
- 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 秒の伝播遅延はやはり待ち遠しく感じることがあります。
ベストプラクティス:
- 頻繁に更新するファイルは、新しいパスにアップロードする
// こうしない:毎回同じファイルを更新
await storage.from('images').upload('logo.png', file, { upsert: true })
// こうする:毎回新しいファイル名を生成
const version = Date.now()
await storage.from('images').upload(`logo-${version}.png`, file)
- cacheNonce でキャッシュを強制的にバイパスする
const { data } = supabase.storage
.from('images')
.getPublicUrl('logo.png', {
cacheNonce: Date.now().toString() // リクエストごとに異なる
})
- 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 Storage | S3 価格に準拠 | CDN は別料金 | Pro プランに含む | Auth 連携、RLS |
| Cloudflare R2 | $0.015/GB | ゼロ | 10GB + 1M ops | 送信費用ゼロ |
| AWS S3 | $0.023/GB | $0.09/GB | 5GB/12 か月 | 最強のエコシステム |
| DigitalOcean Spaces | $5/250GB | 込み | なし | 固定料金 |
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 コスト最適化のヒント
- ライフサイクルポリシー:古いファイルを自動で Glacier へアーカイブ
- 画像圧縮:アップロード前に圧縮するか、Supabase の画像変換を使う
- Public bucket でキャッシュ命中率を上げる:公開できるものはできるだけ公開する
- 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 ポリシーが正しく設定されていない。
確認手順:
- bucket が Public か Private かを確認
storage.objectsテーブル上の RLS ポリシーを確認- ポリシーが 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 について、ほかに質問や踏んだ落とし穴はありますか? ぜひコメント欄で交流しましょう。
参考資料
- Storage CDN | Supabase Docs
- Storage Access Control | Supabase Docs
- Resumable Uploads | Supabase Docs
- Image Transformations | Supabase Docs
- Smart CDN | Supabase Docs
- Cloud Storage Pricing | BuildMVPFast
- Supabase Storage v3: Resumable Uploads
- GitHub Issue #563 - TUS Upload Stalling
Supabase Storage のファイルアップロード完全フロー
bucket の作成から RLS ポリシーの設定まで、安全で制御可能なファイルアップロードを実現する
⏱️ 目安時間: 30 分
- 1
ステップ1: Storage Bucket を作成する
Supabase Dashboard で bucket を作成します:
• Storage ページに移動し、"Create a new bucket" をクリック
• bucket 名を入力(avatars、documents など)
• Public または Private モードを選択
• Public bucket のファイルは直接アクセス可能、Private は署名付き URL が必要 - 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: 標準アップロードを実装する
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: TUS 分割アップロードを設定する
大きいファイル(5MB 超)は TUS プロトコルで扱います:
• 依存をインストール:npm install @uppy/tus tus-js-client
• chunkSize: 6 * 1024 * 1024 を設定(必ず 6MB)
• Authorization header で JWT token を渡すよう設定
• アップロード URL の有効期限は 24 時間 - 5
ステップ5: CDN キャッシュを最適化する
キャッシュ命中率を高める要点:
• Public bucket のキャッシュ命中率が最も高い
• 頻繁に更新するファイルは上書きではなく新しいパスを使う
• cacheNonce でキャッシュを強制的に更新
• Signed URL のキャッシュを再利用し、毎回の生成を避ける
FAQ
Supabase Storage の chunkSize はなぜ 6MB でなければならないの?
Public bucket と Private bucket はどう選べばいい?
• Public bucket:サイトの静的リソース、公開画像、ブログの挿絵など、誰でもアクセスできるもの
• Private bucket:ユーザーの非公開ファイル、会員向けコンテンツ、機密文書など、Signed URL や RLS ポリシーでアクセスを制御する必要があるもの
ファイルを更新したのに、なぜ古いバージョンのまま?
Supabase Storage と Cloudflare R2 はどちらが安い?
• ダウンロード量が多い:R2 は送信(egress)費用ゼロでお得(S3 の送信費用は $0.09/GB)
• Auth 連携が必要:Supabase Storage が便利(RLS ポリシーをそのまま再利用できる)
• すでに Supabase を使用:Storage が自然な選択
• 純粋なオブジェクトストレージ:R2 のコストが低い
RLS ポリシーで、ユーザーが自分のファイルだけにアクセスできるようにするには?
```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 のキャッシュ命中率が低いときは?
5分で読めます · 公開日: 2026年4月14日 · 更新日: 2026年6月8日
Supabase 実践ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Supabase Realtime 実践:WebSocket 接続管理と切断再接続戦略
Supabase Realtime の実践テクニックを詳解。WebSocket 接続管理、切断再接続戦略、Postgres Changes によるリアルタイム購読を網羅。Broadcast・Presence・Postgres Changes の3機能の選定と本番環境のベストプラクティスを習得
第 6 / 10 記事
次の記事
Supabase Edge Functions 実践:Deno ランタイムと TypeScript 開発ガイド
Supabase Edge Functions 開発を深く学ぶ実践ガイド。Deno ランタイムのアーキテクチャと V8 isolate の仕組みを理解し、CLI コマンドの流れをマスターし、Hono フレームワークで RESTful API を構築。ローカルデバッグから本番デプロイまで完全網羅
第 8 / 10 記事
関連記事
Supabase 入門:PostgreSQL + Auth + Storage のオールインワンバックエンド
Supabase 入門:PostgreSQL + Auth + Storage のオールインワンバックエンド
Supabase データベース設計:テーブル構造・リレーション・Row Level Security 完全ガイド
Supabase データベース設計:テーブル構造・リレーション・Row Level Security 完全ガイド
Supabase Auth 実践:メール認証・OAuth・セッション管理
コメント
GitHubアカウントでログインしてコメントできます