「メールを送信しました」
自社で使っている案件管理の画面は、そう表示していました。発注書をPDFにして、ダウンロード用のリンクをメール本文に入れて送る機能です。ところが、届いたメールにはPDFのリンクだけがありませんでした。本文はきちんと届いている。リンクの1行だけが、黙って消えている。
調べていくと、裏側でファイルの保存が毎回 403 new row violates row-level security policy というエラーで失敗していました。原因は、Supabase Storage(データベースやファイル置き場をまとめて借りられるクラウドサービス「Supabase」の、ファイル置き場の部分)にアップロードするときに付けていた upsert: true という、たった1つの指定です。
私(UMEO CREATE・伊藤)の環境で、2026年8月に実際にあった話です。この記事では、なぜ「書き込みの許可はあるのに403になる」のか、同じ条件で試した200と403の実測、直し方、そして原因が1か月前の自分のセキュリティ対策だったという経緯を、いま動いているコードつきでまとめます。Supabaseで社内ツールを作っている方、これから作る方に向けた記事です。
この記事の目次(9項目)
結論:upsertは「読み取りの許可」も必要になる
先に結論です。
- ふつうのアップロードに必要な許可は INSERT(追加)だけ
upsert: true を付けると、追加で SELECT(読み取り)と UPDATE(更新) が必要になる- 当時の私の環境には INSERT・UPDATE・DELETE の3つしか無く、SELECT だけが欠けていた
- そのため、書き込みの許可があるのに、アップロードが毎回403で落ちていた
upsert(アップサート)は、「同じ名前のファイルがあれば上書きし、なければ新しく保存する」という指定です。便利なので、深く考えずに付けてしまいがちです。
書庫にたとえると分かりやすくなります。係の人に「この書類を棚に入れてください」と頼むだけなら、棚に入れる許可があれば足ります。ところが「同じ名前の書類がすでにあれば差し替えて、なければ新しく入れてください」と頼むと、係の人はまず棚を見て確かめる必要があります。棚を見る許可を持っていない係の人は、入れる許可を持っていても、この頼まれ方では仕事ができません。
Supabaseの公式ドキュメントにも、アップロードに必要なのは INSERT の許可だけであること、upsertで上書きできるようにするには SELECT と UPDATE の許可を追加で与える必要があることが書かれています(出典は記事の末尾に置きました)。
アップロードの種類ごとに必要な許可をまとめた表。通常のアップロードはINSERTのみ、upsertはINSERT・SELECT・UPDATEの3つ
何が起きていたか:送信は成功、PDFのリンクだけ消える
問題の機能は、次の順番で動きます。
1. 画面の発注書をPDFにする
2. PDFをSupabase Storageにアップロードする
3. アップロードしたPDFの公開URLを受け取る
4. そのURLをメール本文に入れて送信する
2番目のアップロードが403で失敗していました。それなのに、4番目の送信は成功していました。理由は、当時のコードがアップロードの失敗を「握りつぶして」いたからです。考え方としては次のようなものでした。
// ★やってはいけない例(当時の考え方)
let pdfUrl = '';
try {
pdfUrl = await generateAndUploadOrderPdf(order, project, issuer);
} catch (e) {
console.error(e); // 記録するだけで、処理は先へ進む
}
await sendMail(buildBody(order, pdfUrl)); // pdfUrl が空のまま送信
「PDFが付けられなくても、本文だけは送れたほうが親切だろう」という作りです。結果として、送信成功と表示されるのに、肝心のPDFが黙って消えるという、いちばん気づきにくい壊れ方になりました。
2026年8月21日、実際の取引先あての発注書メールでこれを踏みました。送ったあとで、メールにPDFのリンクが無いことに気づいた、という順番です。発注書は金額と納期が載る書類です。本文だけ届いて書類が無い、という状態は、相手から見ればただの不備です。
原因の切り分け:同じ条件で200と403を出す
翌8月22日に原因を調べました。正直に書くと、最初の2つの見立てはどちらも外れでした。
外れた見立て1:Supabase側の不調。 同じ時期に、データベースへの管理用の接続がタイムアウトしていたので、「サービス全体の調子が悪いのだろう」と考えました。実際には別々の出来事で、アップロードの失敗は自分のアプリの使い方が原因でした。
外れた見立て2:ログインの証明書(トークン)の形式が合っていない。 これは、わざと壊したトークンを送ることで否定できました。壊したトークンを送ると、返ってくるのは signature verification failed という別のエラーです。つまり、Storage側はトークンをきちんと検証できている。正しいトークンを送ったときだけ出る403は、検証の問題ではなく、許可の問題だと分かります。
この「わざと壊した値を送って、エラーの文面が変わるかを見る」やり方は、切り分けにとても役立ちました。もう1つ、ブラウザに保存されていたトークンが期限切れのこともあります。その場合は "exp" claim timestamp check failed という、これもまた別のエラーになります。3つのエラーは文面で見分けられます。
| 返ってくるエラー | 意味 |
|---|
signature verification failed | トークンそのものが壊れている・偽物 |
"exp" claim timestamp check failed | トークンの期限切れ |
new row violates row-level security policy | トークンは正しい。許可(ポリシー)が足りない |
ここまで来て、ようやく本命の実験をしました。同じトークン、同じ保存先のパスで、upsert の指定だけを変えてアップロードします。
upsert なし → 200(成功)upsert あり → 403(失敗)
違いは1か所だけです。これで原因が確定しました。
同じトークン・同じパスで、upsertなしは200、upsertありは403になった実測の比較図
なお、このバケット(ファイルを入れる箱)は「公開」の設定でした。「公開なのだから読めるはず」と考えてしまいそうですが、私の環境で確かめた範囲では、公開の設定が効くのは公開URLでファイルを開くときの話です。アプリが「同じ名前のファイルがあるか」を調べるときの読み取りは、RLS(行レベルセキュリティ。データ1件ごとに、誰が何をしてよいかを決める仕組み)のSELECTの許可で判定されます。建物の正面玄関が開いていることと、係の人が書庫の棚を調べてよいことは、別の話でした。
原因は1か月前の自分のセキュリティ対策だった
もう1つ、書いておくべきことがあります。この機能は、作った当初は動いていました。
Storageに残っているファイルの記録を、この記事を書くにあたって確かめました(2026年10月7日)。発注書のPDFは、7月6日に4件、7月15日に1件、正常にアップロードされています。その次の記録は8月27日です。7月16日から8月22日に直すまでのあいだ、成功した記録は1件もありません。
そのあいだに何があったか。7月22日に、私はこの仕組みのセキュリティを見直しています。そのとき、バケットに付いていた「誰でもファイルの一覧を見られる」読み取りのポリシーを削除しました。判断の根拠は「アプリが使っているのはアップロードと公開URLの取得だけだから、読み取りの許可を消しても影響はない」でした。
この判断が誤りでした。アップロードには upsert: true が付いていて、裏側で読み取りを使っていたからです。
- 7月6日:発注書PDFのアップロード機能を追加
- 7月22日:セキュリティの見直しで、読み取りのポリシーを削除
- 8月21日:取引先あてのメールでPDFのリンクが消えていることに気づく
- 8月22日:原因を特定して修正
記録から言えるのは「削除の前は成功していて、削除のあとは直すまで成功が無い」というところまでです。ただ、仕組みの上でも時期の上でも、穴をふさいだ自分の手が、1か月後の不具合の引き金だったと見ています。
セキュリティを締める作業は正しいことです。ただ、「この許可は使っていないはず」という判断は、コードに書いた関数の名前だけを見ていると外します。upload という1つの呼び出しが、指定しだいで裏側の動きを変えるからです。
7月6日の機能追加から8月22日の修正までを並べた時系列の図。7月22日のポリシー削除のあと、成功の記録が途切れている
直し方:upsertを外す。必要ならSELECTのポリシーを足す
対処は2つあります。私は両方やりました。
1. upsertが本当に必要かを見直す
私の場合、保存先のパスには時刻が入っていました。同じ名前のファイルが二度できることは、そもそもありません。upsertは最初から要らなかったのです。いま動いているコードは次のとおりです(バケット名だけ置き換えています)。
const path = `orders/${order.id}/${Date.now()}_${buildDocumentNo(order)}.pdf`;
// ★upsert は使わない。
// パスに Date.now() が入るので衝突しようがなく、upsert は元々不要だった。
const { error } = await supabase.storage.from('documents').upload(path, blob, {
contentType: 'application/pdf',
});
if (error) throw error;
const { data } = supabase.storage.from('documents').getPublicUrl(path);
return data.publicUrl as string;
上書きが必要ないなら、外すのがいちばん確実です。必要な許可が INSERT の1つで済み、あとから許可を整理しても壊れません。
2. 上書きが必要なら、SELECTのポリシーを足す
同じ名前で上書きしたい場面(プロフィール画像の差し替えなど)では、upsertが必要です。その場合は、読み取りのポリシーを足します。
create policy "documents authenticated select"
on storage.objects for select
to authenticated
using (bucket_id = 'documents');
「ログインしている利用者は、このバケットのファイルを読み取ってよい」という意味です。私の環境では、これを入れたあと、upsertつきのアップロードも200で通ることを確かめました。この記事を書くにあたってあらためて確認したところ、現在は SELECT・INSERT・UPDATE・DELETE の4つがそろっています。
注意点が1つあります。このポリシーは、ログインしている人なら全員がバケット内のファイルを読めるという許可です。私のところは招待した少人数だけが使う仕組みなので、この範囲で足しました。利用者が多い仕組みでは、「自分がアップロードしたファイルだけ」のように条件を絞る必要があります。
3. 失敗を握りつぶさない
アップロードが失敗したのに送信が成功していた、という作りも直しました。いまは、PDFのアップロードに失敗したら送信を中止し、エラーを画面に出します。 書類の付いていない発注メールは、送らないほうがましだからです。
読み取りの許可が無いと、削除と一覧も静かに失敗する
調べる過程で、もう1つ分かったことがあります。読み取りのポリシーが無いあいだ、ファイルの一覧と削除も、エラーを出さずに失敗していました。 削除するには、まず「どのファイルか」を読み取る必要があるからです。
さらに、私のアプリのファイル削除は、Storage側の削除が失敗しても、データベースの記録だけは消してしまう作りでした。画面からはファイルが消えて見えます。しかしStorageには実体が残っています。こうした「持ち主のいないファイル」が溜まっている可能性がありますが、ここはまだ調べきれていません。分かっていない、と正直に書いておきます。
アップロードの403は、エラーが出るぶんまだ親切でした。削除と一覧は、何も言わずに空振りします。
自分のコードで確かめる5つのこと
同じ穴がないかを確かめるときの観点です。
upsert: true を検索する。 見つけたら、保存先のパスが毎回変わるか(時刻や連番が入っているか)を見ます。変わるなら、upsertは外せます- バケットのポリシーを一覧にする。 SELECT・INSERT・UPDATE・DELETE のうち、どれがあってどれが無いかを書き出します。「公開バケットだから大丈夫」とは考えません
- 許可を消すときは、関数の名前ではなく指定まで見る。
upload は、upsert の有無で必要な許可が変わります - アップロードの失敗を握りつぶしていないか。
catch の中が記録だけで、処理が先へ進む箇所を探します。失敗したら止める、を基本にします - 直したら、同じ条件で成功と失敗の両方を出す。 今回は、upsertの有無だけを変えて200と403を並べました。成功だけ見ても、原因が合っているかは分かりません
「成功と表示されるのに、中身が欠けている」という壊れ方は、以前書いたGASでMeetのURLを発行する方法|乱数で作った偽URLの失敗と同じ形です。あちらは実在しないURLが、こちらは空のリンクが、成功の顔で通っていました。
まとめ:403の原因は、書き込みではなく読み取りだった
- Supabase Storageの通常のアップロードに必要な許可は INSERT だけ
upsert: true を付けると、SELECT と UPDATE も必要になる。私の環境では SELECT が欠けていた- 同じトークン・同じパスで、upsertなしは200、ありは403。違いはこの1か所だった
- 保存先のパスが毎回変わるなら、upsertは外す。上書きが要るなら SELECT のポリシーを足す
- 引き金は、1か月前に自分で消した読み取りのポリシーだったと見ている。許可を消すときは、関数の指定まで見る
- アップロードの失敗は握りつぶさない。書類の付かないメールは送らない
社内で使う小さな仕組みほど、作った本人しか中身を知りません。そして、うまく動いているように見える期間がいちばん長く続きます。取引先に出ていくものは、「送信成功」の表示ではなく、届いた実物で確かめる。今回の件で残った教訓です。
UMEO CREATEでは、御社の業務に合わせた小さな業務システムの開発を行っています。納品して終わりにせず、今回のような「あとから効いてくる不具合」に備える保守もあわせてご用意しています。料金は全て一例で、ご予算に合ったプランのご提案が可能です。
▼ 業務システム開発について
https://www.umeocreate.com/system.html
出典(2026年10月7日時点)
200と403の実測、エラーの文面、アップロードの記録の件数は、私の環境(2026年8月22日と10月7日)で確かめたものです。Supabaseの仕様は更新されることがあるので、実装の前に公式ドキュメントをご確認ください。
【群馬・北関東の中小企業・ひとり社長のAI導入をサポート】
UMEO CREATE(ウメオクリエイト)では、群馬県(高崎・前橋・太田・伊勢崎・吉岡町など)を中心に、中小企業や個人事業主・ひとり社長様向けの「AI導入・業務自動化・DX伴走コンサルティング」を行っています。
「自社の業務でもAIを使える?」「何から手をつければいいかわからない」「面倒な事務作業を自動化したい」といったご相談は、お気軽に公式LINEまたはWebサイトのお問い合わせフォームよりご連絡ください。