Supabase RLS で service_role が silent に失敗する — TO service_role ポリシーの罠と正しい書き方
結論:TO service_role は PostgREST 経由では機能しない
3行サマリー:
- Supabase service_role API key を使っていても、PostgREST が実行する Postgres ロールは
anonのまま TO service_roleで書いた RLS ポリシーはanonロールに適用されないため、INSERT が通らない(エラーなし)- 正解は
TO public USING (auth.role() = 'service_role')か RLS を無効化して server-side のみでアクセス
今すぐ確認するチェックリスト:
-
TO service_roleで書いた INSERT/SELECT ポリシーがないか確認 -
supabase.from('table').insert({...})の直後にif (error)でエラーをログしているか - サーバー側コードで
createClient(url, SERVICE_ROLE_KEY)を使っているテーブルに RLS が有効か
何が起きたか
masatoman.net のメルマガ自動配信フローを実装した日のことです。
Resend のダッシュボードを見ると 2/2 delivered と表示されており、送信は成功していました。しかし Supabase の newsletter_issues テーブルを開くと、行が 0 件のまま。
エラーログにも何も出ていません。
「Resend は動いた。Supabase への INSERT だけが静かに死んでいる」
Supabase MCP 経由で直接 SQL を打つと INSERT は通りました。つまり問題は PostgREST 経由かどうかにありました。
// これが silent に失敗していたコード
const { error } = await supabase
.from('newsletter_issues')
.insert({ subject, body, sent_at: new Date().toISOString() })
// error チェックをしていなかった(これが問題の一因)
技術的な原因:PostgREST と service_role の関係
PostgREST は JWT を受け取ったとき、以下の 2 つを別々に扱います。
| 項目 | 内容 |
|---|---|
| Postgres ロール | JWT の role claim が anon なら anon、authenticated なら authenticated |
| JWT claim | service_role かどうかは JWT の role claim で確認できる |
service_role API key を使った場合、JWT の role claim は service_role になります。しかし PostgREST が実際に Postgres に接続するときのロールは anon のままです。
これが混乱の核心です。
-- ❌ これは PostgREST 経由では動かない
CREATE POLICY "service_role can insert"
ON newsletter_issues
FOR INSERT
TO service_role -- ← この TO service_role は Postgres ロールを指す
WITH CHECK (true);
-- PostgREST は anon ロールで接続しているため、このポリシーは適用されない
-- → INSERT は「ポリシーなし」扱い → silent に失敗
では newsletter_subscribers テーブルはなぜ動いていたのか? 確認すると、そちらのポリシーはこう書かれていました。
-- ✅ こちらは正しく動いていた既存ポリシー
CREATE POLICY "service_role can insert subscribers"
ON newsletter_subscribers
FOR INSERT
TO public -- ← anon ロールも含む
WITH CHECK (auth.role() = 'service_role'); -- ← JWT claim でチェック
TO public にすることで anon ロールでも対象になり、auth.role() で service_role JWT かどうかを判別しています。
PostgREST の動作を図解
クライアント(server-side コード)
↓
service_role API key で supabase-js を初期化
↓
PostgREST に HTTP リクエスト
↓
PostgREST が JWT を検証
↓ JWT の role claim = 'service_role'
↓ しかし Postgres 接続ロールは 'anon'(仕様)
↓
RLS ポリシーチェック
↓ TO service_role のポリシー → anon には適用されない
↓
INSERT 失敗(エラーなし、0 rows)
Supabase の公式トラブルシューティングにも記載があります(Why is my service role key client getting RLS errors?)。しかし、エラーが出ないのでこのページに辿り着けないのが難しいところです。
正しい実装パターン 3 つ
パターン A:TO public + auth.role() チェック(推奨)
-- サーバー側からのみ書き込む管理テーブルに使う
CREATE POLICY "server only insert"
ON newsletter_issues
FOR INSERT
TO public
WITH CHECK (auth.role() = 'service_role');
CREATE POLICY "server only select"
ON newsletter_issues
FOR SELECT
TO public
USING (auth.role() = 'service_role');
使いどころ: メルマガ配信ログ、決済イベント記録など、クライアントからは絶対に触らせないテーブル。
パターン B:RLS を無効化してサーバー限定アクセス
-- RLS をオフにして、アクセス手段をサーバー限定にする
ALTER TABLE newsletter_issues DISABLE ROW LEVEL SECURITY;
-- GRANT はサービス内のみに絞る(publicには渡さない)
REVOKE ALL ON newsletter_issues FROM anon, authenticated;
GRANT ALL ON newsletter_issues TO service_role;
使いどころ: 内部管理テーブルで、クライアントに一切 expose しない場合。
パターン C:supabaseAdmin クライアントを分離(最もシンプル)
// lib/supabase/admin.ts
import { createClient } from '@supabase/supabase-js'
export const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
)
// このクライアントは RLS をバイパスする
// → Server Component / API Route / cron からのみ使用
supabaseAdmin は RLS をバイパスするため、ポリシーを書かなくても INSERT できます。ただし クライアントサイドに絶対に渡してはいけない。
silent failure を防ぐ:error チェックを絶対に入れる
supabase-js は INSERT 失敗時に例外を throw しません。{ data, error } の error を必ずチェックする必要があります。
// ❌ error チェックなし — silent failure を見逃す
await supabase.from('newsletter_issues').insert({ subject, body })
// ✅ 正しい error ハンドリング
const { data, error } = await supabase
.from('newsletter_issues')
.insert({ subject, body, sent_at: new Date().toISOString() })
if (error) {
console.error('[newsletter_issues insert failed]', {
code: error.code,
message: error.message,
details: error.details,
hint: error.hint,
})
throw new Error(`DB insert failed: ${error.message}`)
}
RLS による silent failure は error.message に "new row violates row-level security policy" が含まれる場合もありますが、ポリシーが存在しない(TO service_role で書いていて anon に届いていない)ケースでは error 自体が null になり 本当に何も出ないことがあります。
定期的に SELECT COUNT(*) FROM newsletter_issues を監視するか、送信後にレコード数を検証するコードを入れるのが確実です。
// 送信後の検証(パターン例)
const { count } = await supabaseAdmin
.from('newsletter_issues')
.select('*', { count: 'exact', head: true })
.eq('subject', subject)
if (!count || count === 0) {
// アラート発火 / Slack 通知
}
まとめ:RLS ポリシーの TO 句チェックリスト
| 状況 | 正しいパターン |
|---|---|
| server-side からのみ INSERT/SELECT | TO public WITH CHECK (auth.role() = 'service_role') |
| 認証ユーザーが自分のデータを読む | TO authenticated USING (auth.uid() = user_id) |
| 匿名も読める公開データ | TO anon USING (is_public = true) |
| 管理用テーブル(外部 expose なし) | RLS 無効 + REVOKE で anon/authenticated を排除 |
TO service_role を単独で使うケースはほぼ存在しないと考えておくと混乱を防げます。
masatoman.net の newsletter_issues テーブルで実際に踏んだ silent failure。Resend 側では送信成功なのに DB が空という状態が発生し、デバッグに時間を要した。現在は TO public WITH CHECK (auth.role() = 'service_role') パターンに修正済み。GA4 + Search Console 連携済みでベースライン計測中。節目で数字を公開します。
で、どう稼ぐ?
このバグを踏んだとき、最悪のケースは「メルマガ送信履歴が残らないまま課金が発生し続ける」状態でした。Stripe でサブスク料金が引き落とされたのに配信ログがない、という状況は返金トラブルに直結します。
収益化インフラが壊れていると気づけない状態が一番こわい。
具体的な収益導線として:
- Supabase RLS 設計を正しく理解する → サブスクリプション管理・有料コンテンツゲートが安全に実装できる
- error ハンドリングを徹底する → 支払い処理・メルマガ送信・ユーザーデータ書き込みの失敗を検知できる
- 管理テーブルと公開テーブルを分ける → service_role でアクセスするテーブルと、authenticated/anon が読むテーブルを設計段階で分離する
この設計の詳細(Stripe サブスク管理テーブルへの RLS 適用パターン)は Claude Crew Lab の記事で公開予定です。
Free 登録で更新通知を受け取れます。
masatoman のメルマガ — 毎週月曜の朝に手紙を 1 通
masatoman.net の今週の記事 1 本を、読者目線で深掘りした手紙が毎週月曜 9:00 に届きます。「これ自分のことだ」が見つかる予告編。登録特典に「個人開発の収益化チェックリスト 15 項目」。
masatoman のメルマガ — 毎週月曜の朝に 1 通
masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。
Next Step
次に読むならこの導線です
【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード
Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude 自動化」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。
【第10回】Claude Code × Supabase で管理画面を30分で生成する Skill 実装ガイド
Supabase のテーブルを参照して管理画面を自動生成する Skill を、実コード付きで解説。Claude Code が DB スキーマから Next.js 管理画面を30分で生成する仕組みを公開します。
【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード
Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude 自動化」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。
Claude Code で 0→MVP を1日で作る全記録 — recipe-ai Build in Public
Claude Codeを使い、YouTube料理動画からレシピを自動抽出するAIアプリ「recipe-ai」を0からMVPまで1日で構築した全記録。CLAUDE.md設計、API実装、Supabase連携、Vercelデプロイ、Stripe課金導入までの工程を時系列で公開。
次の実験記録も追う
Claude Code × 個人開発の実験ログ、失敗、判断変更をまとめて追いたい人向けに、月次でLab Freeを届けます。
masatoman のメルマガ — 毎週月曜の朝に 1 通
masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。
この記事が役に立ったらシェア