メインコンテンツへスキップ
← 記事一覧に戻る
·開発·10 min read

Supabase RLS で service_role が silent に失敗する — TO service_role ポリシーの罠と正しい書き方

SupabaseRLSPostgRESTNext.js個人開発

結論: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 なら anonauthenticated なら authenticated
JWT claimservice_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/SELECTTO 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 でサブスク料金が引き落とされたのに配信ログがない、という状況は返金トラブルに直結します。

収益化インフラが壊れていると気づけない状態が一番こわい。

具体的な収益導線として:

  1. Supabase RLS 設計を正しく理解する → サブスクリプション管理・有料コンテンツゲートが安全に実装できる
  2. error ハンドリングを徹底する → 支払い処理・メルマガ送信・ユーザーデータ書き込みの失敗を検知できる
  3. 管理テーブルと公開テーブルを分ける → 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

次に読むならこの導線です

すべての記事を見る
有料で次へ進む¥1,000

【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード

Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude 自動化」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。

次の実験記録も追う

Claude Code × 個人開発の実験ログ、失敗、判断変更をまとめて追いたい人向けに、月次でLab Freeを届けます。

masatoman のメルマガ — 毎週月曜の朝に 1 通

masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。

この記事が役に立ったらシェア