アップロードは成功しているのに、画像を表示しようとすると真っ白になります。ログを見ると 403 Forbidden。
個人でアプリ開発をしていると、こういう「動くはずなのに動かない」問題に何時間も消えることがあります。2014年からアプリ開発を続けてきた中で気づいたのは、認証や権限まわりのエラーは「コードが間違っている」より「設定の組み合わせが想定と違う」ことで起きる場合の方がずっと多い、ということです。
Rork の AI が生成するコードは Supabase Storage の API を正しく使いますが、Supabase のバケット設定や Row Level Security(RLS)の設定はダッシュボード側で行う必要があります。ここのギャップで引っかかる方が多いため、今回は 403 エラーの主な原因3パターンと、それぞれの解決方法を整理しました。
403 エラーが起きる3つの原因パターン
Supabase Storage で 403 が返ってくる場合、原因はほぼ次の3つに絞られます。
- パターン1: バケットが非公開(Private)なのに
getPublicUrl()で公開 URL を取得しようとしている - パターン2: RLS ポリシーが有効だが、SELECT(読み取り)を許可するポリシーを設定していない
- パターン3:
getPublicUrl()とcreateSignedUrl()の使い分けを誤っている、またはアップロード直後に URL の反映が遅れている
順番に確認していきましょう。
パターン1: バケットの Public/Private 設定とコードが合っていない
Supabase Storage のバケットには「Public」と「Private」の2種類があります。Rork でデフォルトのコードを生成すると、多くの場合 getPublicUrl() を使うコードが出力されます。
// Rork が生成するよくあるコード
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(`${userId}/profile.jpg`);
// data.publicUrl を Image コンポーネントに渡すこのコードが 403 になる場合、まず Supabase ダッシュボードで avatars バケットが「Public」になっているかを確認してください。
ダッシュボードでの確認手順:
- Supabase ダッシュボード → Storage → Buckets を開く
- 対象バケットの「Public」スイッチがオンになっているかを確認する
- オフになっていれば、バケットを Public に変更するか、
createSignedUrl()を使う実装に切り替える
Private バケットで署名付き URL を使う場合:
// Private バケットの場合は createSignedUrl を使う
const { data, error } = await supabase.storage
.from('avatars')
.createSignedUrl(`${userId}/profile.jpg`, 3600); // 有効期限: 3600秒 = 1時間
if (error) {
console.error('署名付きURL取得エラー:', error.message);
return;
}
// data.signedUrl を Image に渡す(nullチェックを忘れずに)
if (data?.signedUrl) {
setImageUrl(data.signedUrl);
}署名付き URL は有効期限があるため、アプリのユースケースに応じて期限を調整してください。ユーザープロフィール画像のように頻繁に表示するものは期限を長め(24時間〜7日間)に設定するのが実用的です。
パターン2: RLS ポリシーに SELECT 許可が設定されていない
バケットが Public であっても、Storage オブジェクトに対する RLS ポリシーが有効になっていると、ポリシーで明示的に許可されていない操作はすべて拒否されます。
確認すべき箇所:
- Supabase ダッシュボード → Storage → Policies を開く
storage.objectsテーブルに対してSELECT操作を許可するポリシーが存在するかを確認する
存在しない場合は、以下の SQL でポリシーを追加できます。
全ユーザーに公開バケットの読み取りを許可する例:
-- 公開バケットのファイルを全ユーザーが読み取れる
CREATE POLICY "Public avatars are viewable by everyone"
ON storage.objects
FOR SELECT
USING (bucket_id = 'avatars');認証済みユーザーのみ自分のファイルを読み取れるようにする例:
-- 認証済みユーザーが自分のフォルダ内のファイルのみ読み取れる
CREATE POLICY "Users can view their own avatar"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'avatars' AND
(storage.foldername(name))[1] = auth.uid()::text
);storage.foldername(name) はファイルパスの最初のセグメントを返す Supabase 独自の関数です。ユーザー ID をフォルダ名として使うパターン(userId/filename.jpg)では、これで所有者チェックを実装できます。
INSERT ポリシーと SELECT ポリシーは別々に設定が必要です。「アップロードはできるが表示は 403」という状況は、INSERT ポリシーのみ設定して SELECT ポリシーを忘れている場合によく起きます。
パターン3: getPublicUrl と createSignedUrl の混同・アップロード後の反映遅れ
download() と getPublicUrl() を混同しているケース:
// ❌ よくある間違い: download() でURL文字列を取得しようとしている
const { data, error } = await supabase.storage
.from('avatars')
.download(`${userId}/profile.jpg`);
// data は Blob — Image の source に直接使えない
// ✅ 正しい: URL文字列を取得するには getPublicUrl または createSignedUrl を使う
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(`${userId}/profile.jpg`);
// data.publicUrl は文字列 — そのまま Image の source に使えるdownload() はファイルをバイナリ(Blob)として取得するメソッドです。UI に画像を表示したい場合は、URL を取得する getPublicUrl() または createSignedUrl() を使ってください。
アップロード直後の反映遅れ:
アップロード完了直後に URL を取得して表示しようとすると、稀にストレージの反映が追いつかず 403 になる場合があります。アップロードの成功レスポンスを確認してから URL を取得するコードが望ましいです。
const uploadAndGetUrl = async (file: File, path: string) => {
// まずアップロード
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(path, file, { upsert: true });
if (uploadError) {
throw new Error(`アップロード失敗: ${uploadError.message}`);
}
// アップロード成功後に URL を取得
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(path);
return data.publicUrl;
};Rork AI が生成するコードで起きやすい追加の落とし穴
バケット名のスペルミス
AI が画面ごとにコードを生成する際、アップロード時と取得時でバケット名が微妙に違ってしまうことがあります。
// アップロード時
await supabase.storage.from('user-avatars').upload(path, file);
// 取得時(バケット名が違う ─ これが 403 の原因)
const { data } = supabase.storage
.from('avatars') // ❌ 'user-avatars' であるべき
.getPublicUrl(path);バケット名を定数として管理すると、このミスを防げます。
// constants/storage.ts として定義しておく
export const STORAGE_BUCKETS = {
AVATARS: 'user-avatars',
POSTS: 'post-images',
} as const;
// 使用時
await supabase.storage.from(STORAGE_BUCKETS.AVATARS).upload(path, file);
const { data } = supabase.storage.from(STORAGE_BUCKETS.AVATARS).getPublicUrl(path);パスの先頭スラッシュ
// ❌ 先頭にスラッシュが入っている
const path = `/${userId}/profile.jpg`;
// ✅ 先頭スラッシュなし
const path = `${userId}/profile.jpg`;Supabase Storage のパスは先頭スラッシュなしで統一することを推奨します。バージョンによって挙動が異なる場合があるため、プロジェクト内で統一しておくとデバッグがシンプルになります。
画像アップロード実装後の動作確認チェックリスト
- [ ] Supabase ダッシュボードでバケットの Public/Private 設定を確認した
- [ ] コードで使っている
getPublicUrlまたはcreateSignedUrlがバケット設定と合っている - [ ] Storage → Policies で
SELECT操作を許可するポリシーが存在する - [ ] アップロードと取得で同じバケット名・パスを使っている(タイポなし)
- [ ]
getPublicUrl()が返す URL をブラウザで直接開いて 403 が出ないことを確認した
この順番で確認すると、ほとんどのケースは原因を特定できます。
Rork での Supabase Storage の基本的なセットアップについては Rork + Supabase Storage 画像アップロード実装ガイド が参考になります。また、RLS の設計パターンについては Supabase Edge Functions と RLS の API 設計 で詳しく解説しています。
権限まわりのデバッグは「どこで何が拒否されているか」を一つずつ切り分けることが大切です。Supabase はダッシュボード上で SQL を直接実行してポリシーのテストもできます。焦らず一つずつ確認していくと、必ず原因にたどり着けます。