Supabase Storage でアバター画像を管理する — バケット設計と RLS の実践

2026.02.12
Share:

プロダクトにプロフィール機能を追加するとき、最初にぶつかるのが「アバター画像をどこにどう保存するか」という問題です。外部のオブジェクトストレージを契約するか、CDN を別途用意するか、あるいはデータベースに直接保存するか。選択肢は多いのに、どれも一長一短があります。

Wakulier のプロフィール設定機能を開発したとき、この問題に直面しました。Wakulier はフリーランスや副業クリエイターが依頼管理に使うツールです。クライアントとのやり取りの中で「誰に依頼しているか」が一目で分かることが大切で、そのためにアバター画像と表示名は欠かせない要素でした。

この記事では、Supabase Storage を使ったアバター画像管理の設計パターンと、プロフィール情報全体の設計で学んだことを紹介します。以前書いた「Supabase RLS の設計パターン」の実践編として、Storage 固有の RLS 設計にも踏み込みます。

プロフィール機能が必要になった背景

Wakulier では、Google アカウントで OAuth 認証を行っています。ログインすれば自動的に名前とアイコンが取得されます。しかし、開発を進める中で「Google アカウントの情報をそのまま使いたくないユーザーがいる」ということに気づきました。

2026年1月4日の開発日記にはこう書いています:

プロフィール設定: Googleアカウントの名前・アイコンをそのまま使いたくないユーザー向け

仕事用のツールで個人の Google アイコンがそのまま表示されることに抵抗があるユーザーは少なくありません。特に副業でクリエイターをしている場合、本業のアカウントと分けたいというニーズがあります。そこで、Google アカウントの情報を「上書き可能」にするプロフィール設定機能を作ることにしました。

実装すべき項目は3つでした:

  • 表示名の編集(Google アカウント名を上書きできる)
  • アバター画像のアップロード(Supabase Storage を利用)
  • テナント名の編集(サービス提供者のみ)

なぜ Supabase Storage を選んだか

アバター画像の保存先としては、AWS S3、Cloudflare R2、Firebase Storage なども候補に挙がります。しかし Wakulier では、認証(Supabase Auth)、データベース(PostgreSQL)、リアルタイム通信(Supabase Realtime)をすべて Supabase で統一していました。

ここで Storage も Supabase に揃える最大のメリットは、RLS との統合です。Supabase Storage は内部的に PostgreSQL のテーブル(storage.objects)で管理されているため、データベースと同じ RLS ポリシーの仕組みでアクセス制御ができます。「自分のファイルだけ操作できる」「読み取りは認証済みユーザー全員に許可する」といったルールを SQL で定義できるのは、追加のミドルウェアが不要になるという意味で大きな利点でした。

バケット設計 — public か private か

ストレージ設計で最初に判断が必要だったのは、バケットを public にするか private にするかです。

Supabase Storage には2種類のバケットがあります。public バケットは URL を知っていれば認証なしでアクセスできます。private バケットは、認証済みユーザーが Supabase SDK 経由でアクセスする必要があります。

「アバター画像はプロフィールに表示するものだから public でいい」という考え方もあります。しかし、Wakulier はフリーランスと クライアントの間の業務ツールです。誰でもアクセスできる URL にユーザーの顔写真が置かれるのは、プライバシーの観点から避けたいと考えました。

ZeronovaZeronova
アバター画像のバケットは private にして、RLS で認証済みユーザーだけアクセスできるようにしたい。ただ、画像の表示に毎回トークンが必要になると、パフォーマンスに影響しないか気になる。
Claude Code
Supabase Storage の private バケットでは、createSignedUrl で署名付き URL を発行できます。有効期限を設定すれば、その間はトークンなしでアクセス可能です。アバター画像の場合、1時間程度の有効期限にすれば、ページ表示のたびに署名を取得し直すオーバーヘッドは許容範囲です。また、RLS で SELECT ポリシーを認証済みユーザー全員に許可すれば、Supabase SDK の getPublicUrl の代わりに createSignedUrl を使うだけで済みます。
ZeronovaZeronova
なるほど、署名付き URL なら URL が流出しても有効期限が切れるから安全か。private バケットで進めよう。パス構成は {user_id}/{timestamp}_{filename} でいきたい。ユーザーごとにフォルダ分けして、タイムスタンプで古いファイルとの衝突を避ける方針で。

この判断に基づいて、以下のバケット設計で Claude Code に実装を依頼しました:

  • バケット名: avatars
  • 公開設定: private(RLS で制御)
  • パス構成: {user_id}/{timestamp}_{filename}

パスにタイムスタンプを含めたのは、同じファイル名で画像を上書きアップロードしたときに、ブラウザのキャッシュが古い画像を表示し続ける問題を避けるためです。タイムスタンプが異なれば URL も変わるので、キャッシュの問題は起きません。

RLS ポリシーの設計

バケットが決まったら、次は RLS ポリシーの設計です。以前の記事「Supabase RLS の設計パターン」で書いた「デフォルト拒否」の原則を、Storage にもそのまま適用しました。

開発日記にもこう記録しています:

Supabase StorageのRLS設定はシンプルに保つ

必要なポリシーは3つだけです:

-- 認証済みユーザーは全員のアバターを閲覧可能
CREATE POLICY "Authenticated users can view avatars"
  ON storage.objects FOR SELECT
  USING (bucket_id = 'avatars' AND auth.role() = 'authenticated');

-- 自分のフォルダにのみアップロード可能
CREATE POLICY "Users can upload own avatar"
  ON storage.objects FOR INSERT
  WITH CHECK (
    bucket_id = 'avatars'
    AND (storage.foldername(name))[1] = auth.uid()::text
  );

-- 自分のファイルのみ削除可能
CREATE POLICY "Users can delete own avatar"
  ON storage.objects FOR DELETE
  USING (
    bucket_id = 'avatars'
    AND (storage.foldername(name))[1] = auth.uid()::text
  );

ポイントは storage.foldername(name) です。この関数はファイルパスからフォルダ名を配列で取得します。パスが {user_id}/{timestamp}_{filename} の構成なので、最初の要素([1]、PostgreSQL の配列は1始まり)が user_id になります。これを auth.uid() と照合することで、「自分のフォルダにだけファイルを操作できる」という制御がデータベース層で完結します。

この設計により、API ルートでは「ログインしているか」の認証チェックだけ行えば十分です。「このユーザーがこのファイルを操作できるか」という認可チェックは RLS が担ってくれます。

表示名の優先順位ロジック

プロフィール機能のもう一つの重要な設計が、表示名の優先順位です。ユーザーが表示名を設定していないケースにも対応する必要があります。

開発日記で検討した選択肢は3つありました:

  1. display_name > full_name > email — メールアドレス全体を表示
  2. display_name || full_name || email — どれか1つだけ使う(OR 条件)
  3. display_name → full_name → email.split("@")[0] — メールのローカルパートを使う

最終的に採用したのは3番目のフォールバック方式です。

function getDisplayName(profile: Profile): string {
  if (profile.display_name) return profile.display_name;
  if (profile.full_name) return profile.full_name;
  return profile.email.split("@")[0];
}

「メールアドレスそのまま表示」を避けたかったのが最大の理由です。user@example.com がヘッダーに表示されるのは見栄えが悪いだけでなく、メールアドレスが他のユーザーに見えてしまうリスクもあります。ローカルパート(@ より前)だけを取り出せば、最低限の識別性を保ちつつプライバシーを守れます。

この優先順位ロジックは、Wakulier のあらゆる場所で使われています。クライアント向けヘッダー、トップページの組織カード、アクティビティカード、依頼詳細ページの「依頼者 → 依頼先」表示。プロフィール情報の表示箇所が増えるにつれて、この「一箇所で定義して全体で使う」設計が効いてきました。

プロフィール編集 UX の工夫

プロフィール情報の保存先とロジックが決まったら、次はユーザーが実際に編集する UI です。この部分も Claude Code に依頼して実装しましたが、PM として UX のポイントをいくつか指定しました。

アバタープレビュー

画像をアップロードする前に、選択した画像をプレビュー表示する仕組みを入れました。URL.createObjectURL を使えば、サーバーにアップロードする前にブラウザ上で画像を確認できます。フリーランスが仕事用のプロフィールに設定する画像なので、意図しない画像を間違えてアップロードしてしまうミスは避けたいところです。

バリデーション

表示名については、空文字や空白だけの入力を弾くバリデーションを設けました。ただし「未設定のまま保存」は許可しています。未設定の場合はフォールバックロジック(Google アカウント名 → メールのローカルパート)が働くので、ユーザーに入力を強制する必要はありません。

画像については、ファイルサイズ(2MB以下)とファイル形式(JPEG、PNG、WebP)の制限を設けました。巨大な画像をアップロードされると Storage の容量を圧迫しますし、Supabase の無料枠で運用している段階ではコスト管理も重要です。

エラーハンドリング

Storage へのアップロードは通信の失敗やファイルサイズ超過でエラーになる可能性があります。エラーが発生した場合は、プレビューをクリアして元のアバター表示に戻し、ユーザーにリトライを促すメッセージを表示する設計にしました。「保存したつもりが保存されていなかった」という状況を防ぐために、保存成功時にはトースト通知で明示的にフィードバックを返すようにしています。

サービス提供者情報の表示

プロフィール機能は単なる「設定画面」ではなく、Wakulier のサービス体験全体に影響します。開発日記に記録したとおり、アップロードしたアバターと表示名は以下の場所に反映されます:

  • クライアント向けヘッダーにサービス提供者の情報として表示
  • トップページの組織カード
  • アクティビティカードのアクターアバター
  • 依頼詳細ページの「依頼者 → 依頼先」の関係性表示

特に「誰から誰への依頼か」を視覚的に明示する機能は、プロフィール設定があってこそ成り立ちます。Google アカウントのデフォルトアイコンが並んでいるだけでは、クライアントは「誰に依頼しているか」を直感的に把握できません。

学び — Storage と RLS の組み合わせの威力

今回のプロフィール機能の実装を通じて、いくつかの学びがありました。

プロフィール情報は「上書き可能」が正解。Google アカウントの情報をデフォルト値として使いつつ、ユーザーが自由に上書きできる設計にしたことで、「Google アイコンのままでいい人」と「仕事用に変えたい人」の両方に対応できました。この柔軟性は、ユーザーに余計な設定を強制しないという意味で UX の向上にもつながっています。

Storage の RLS はデータベースの RLS と同じ考え方で設計できる。以前の記事で書いた「デフォルト拒否」の原則は、Storage にもそのまま適用できます。storage.objects テーブルに対するポリシーという形で、データベースと一貫した設計ができるのは Supabase の強みです。

「誰と誰の関係か」を明示すると、サービスの構造が理解しやすくなる。これは開発日記にも記録した気づきですが、プロフィール情報は単なる飾りではなく、サービスの関係性を可視化する機能です。Wakulier のように「依頼者」と「サービス提供者」という二者の関係があるサービスでは、プロフィール情報がユーザー体験の根幹を支えています。

まとめ

Supabase Storage でアバター画像を管理する設計のポイントを振り返ります:

  1. バケットは private + RLS で制御し、署名付き URL でアクセスする
  2. パス構成は {user_id}/{timestamp}_{filename} でキャッシュ問題を回避する
  3. 表示名のフォールバックdisplay_name → full_name → email ローカルパート の3段階
  4. RLS ポリシーはシンプルに保つ — 読み取り全員OK、書き込み・削除は自分だけ

Supabase を使ったプロダクト開発では、Auth・Database・Storage・Realtime を統一的に設計できることが最大の武器です。RLS という共通の仕組みでアクセス制御を一貫させることで、セキュリティの漏れを防ぎつつ、API ルートのコードをシンプルに保てます。

プロフィール機能は地味に見えますが、ユーザーがプロダクトに「自分の場所」を感じるための大切な要素です。特に業務ツールでは、「誰が誰と仕事をしているか」を可視化することで、サービスの信頼感が大きく変わります。


関連記事

Zeronova avatar

Zeronovaゼロノバ

Product Manager / AI-Native Builder

Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。

関連プロダクト

Wakulier(ワクリア)

継続案件の依頼管理ツール