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

Supabase RLS で安全な有料コンテンツゲートを作る — 設計パターンと実装の落とし穴

SupabaseセキュリティNext.js個人開発

なぜ RLS が「ペイウォール」の核心なのか

有料コンテンツを配信するサービスで、最も恐ろしいのは何か。

「お金を払っていない人がコンテンツを見られる」状態。

クライアント側で if (purchased) { showContent() } のように制御していると、JavaScript を無効化したり、API を直接叩いたりするだけで突破される。

サーバー側でチェックしても、「全ユーザーの購入履歴テーブル」をうっかり全件取得できる API を作ってしまえば終わり。

Supabase Row Level Security(RLS)は、この問題をデータベース層で解決する。

データベース自体が「このユーザーはこの行を見れる/見れない」を判定するため、アプリケーション側のバグでは突破できない。

このブログの有料記事機能も、RLS を有効にしています。仮に Next.js 側のチェックロジックにバグがあっても、データベース層でブロックされる二重の防御が効きます。

この記事でわかること:

  • RLS の基本概念と「2層防御」の設計思想
  • 購入履歴テーブルの RLS ポリシー設計
  • Service Role Key と Anon Key の使い分け
  • Webhook からの書き込みで RLS をバイパスする正しい方法
  • N+1 問題を避ける購入チェックの実装
  • サーバーコンポーネントでの効率的な使い方

RLS の基本: 「行レベル」のアクセス制御

通常のテーブル権限は「テーブル全体」に対して付与する。

GRANT SELECT ON article_purchases TO authenticated;

これだと、認証済みユーザーは全員の購入履歴を見れてしまう。

RLS は「どの行を見れるか」を SQL で記述できる。

ALTER TABLE article_purchases ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can view own purchases"
  ON article_purchases
  FOR SELECT
  USING (auth.uid() = user_id);

これで、Supabase 経由のクエリは自動的に WHERE user_id = '現在のユーザーID' がついた状態で実行される。

別のユーザーの購入履歴を取得しようとしても、結果は空になる。


設計パターン: 購入履歴テーブルの RLS

基本テーブル定義

CREATE TABLE article_purchases (
  id TEXT PRIMARY KEY,                    -- Stripe Checkout Session ID
  user_id UUID REFERENCES auth.users(id),
  customer_email TEXT,
  article_slug TEXT NOT NULL,
  amount INTEGER NOT NULL DEFAULT 0,
  currency TEXT NOT NULL DEFAULT 'jpy',
  status TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

RLS ポリシーの段階設計

Step 1: RLS を有効化

ALTER TABLE article_purchases ENABLE ROW LEVEL SECURITY;

これだけだと「誰も何も見れない」状態になる。RLS が有効でポリシーがない場合、デフォルトは全拒否

Step 2: SELECT ポリシー

CREATE POLICY "Users can view own purchases"
  ON article_purchases
  FOR SELECT
  USING (auth.uid() = user_id);

ユーザーは自分の購入履歴のみ参照可能。

Step 3: INSERT/UPDATE は意図的に許可しない

-- INSERT, UPDATE のポリシーは作らない
-- これにより、Anon Key からの書き込みは全て拒否される

設計判断: 購入記録はクライアントから書き込まれてはいけない。Webhook 経由で Service Role Key を使って書き込む。


Service Role Key の正しい使い方

Anon Key と Service Role Key の違い

Key種別RLS用途
Anon Key適用されるクライアント、認証済みユーザーのアクセス
Service Role Keyバイパスサーバー側、Webhook、管理操作

Service Role Key は RLS を完全に無視する。絶対にクライアントに公開してはいけない。

Webhook から書き込む実装

// src/lib/supabase/server.ts
export async function createAdminClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,  // ← Service Role Key
    {
      cookies: {
        getAll() { return cookieStore.getAll(); },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {}
        },
      },
    }
  );
}

Webhook ハンドラ内では createAdminClient() を使う:

// src/app/api/webhooks/stripe/route.ts
const supabase = await createAdminClient();

await supabase.from("article_purchases").upsert({
  id: session.id,
  user_id: session.metadata?.user_id,
  article_slug: session.metadata?.article_slug,
  status: "completed",
});

これで RLS をバイパスして書き込める。

危険な落とし穴: Service Role Key の漏洩

NEXT_PUBLIC_ プレフィックスをつけた環境変数は全てクライアントにバンドルされる

絶対やってはいけない:

NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJ...

正しい:

SUPABASE_SERVICE_ROLE_KEY=eyJ...

Next.js のビルド時、NEXT_PUBLIC_ プレフィックスがついた環境変数はクライアントの JavaScript バンドルに埋め込まれる。Service Role Key を漏らせば、誰でもデータベースを全権限で操作できるようになる。


購入チェックの実装パターン

サーバーコンポーネントでの基本形

// 記事ページ(Server Component)
const supabase = await createClient();  // ← Anon Key(RLS適用)
const { data: { user } } = await supabase.auth.getUser();

if (!user) {
  // 未ログイン → ペイウォール表示
  return <Paywall />;
}

const { data: purchase } = await supabase
  .from('article_purchases')
  .select('id')
  .eq('user_id', user.id)
  .eq('article_slug', slug)
  .eq('status', 'completed')
  .single();

if (!purchase) {
  return <Paywall />;
}

return <FullArticle />;

ポイント:

  • createClient() は Anon Key を使う → RLS が適用される
  • eq('user_id', user.id) は明示的に書く(RLS でも動くが、明示的な方が早い)
  • .single() で1件取得 → 存在チェック

N+1 問題の回避

複数の有料記事を扱う場合、記事一覧で「購入済みかどうか」を表示したくなる。

悪い実装(N+1):

const articles = getAllArticles();
const purchased = await Promise.all(
  articles.map(a => supabase.from('article_purchases').select('id').eq('article_slug', a.slug).single())
);

これだと記事の数だけクエリが走る。

良い実装(1クエリ):

const articles = getAllArticles();
const slugs = articles.filter(a => a.frontmatter.premium).map(a => a.slug);

const { data: purchases } = await supabase
  .from('article_purchases')
  .select('article_slug')
  .in('article_slug', slugs)
  .eq('status', 'completed');

const purchasedSet = new Set(purchases?.map(p => p.article_slug) ?? []);

// articles.map で purchasedSet.has(slug) をチェック

IN 句で1回だけクエリを発行し、Set でO(1)ルックアップ。


インデックス設計

購入チェックは記事ページが表示されるたびに走る。インデックスを正しく設計しないと、購入者が増えるとクエリが遅くなる。

推奨インデックス

-- ユーザーIDと記事スラッグの複合インデックス(部分インデックス)
CREATE INDEX idx_article_purchases_user_slug
  ON article_purchases(user_id, article_slug)
  WHERE status = 'completed';

ポイント:

  • 複合インデックスで eq('user_id', x).eq('article_slug', y) を高速化
  • 部分インデックス(WHERE status = 'completed')で、未完了の購入を除外しサイズを圧縮

EXPLAIN で確認

EXPLAIN ANALYZE
SELECT id FROM article_purchases
WHERE user_id = 'xxx' AND article_slug = 'yyy' AND status = 'completed';

Index Scan using idx_article_purchases_user_slug が表示されればOK。Seq Scan が出ていたら、インデックスが効いていない。


キャッシュ戦略

Next.js の unstable_cache を使う

毎リクエストでDBを叩くのは非効率。同じユーザーの購入チェックは数秒キャッシュしてもよい。

import { unstable_cache } from 'next/cache';

const checkPurchase = unstable_cache(
  async (userId: string, slug: string) => {
    const supabase = await createClient();
    const { data } = await supabase
      .from('article_purchases')
      .select('id')
      .eq('user_id', userId)
      .eq('article_slug', slug)
      .eq('status', 'completed')
      .single();
    return !!data;
  },
  ['article-purchase-check'],
  { revalidate: 60, tags: ['article-purchase'] }  // 60秒キャッシュ
);

購入完了時に revalidateTag('article-purchase') を呼べば、即座に反映できる。


RLS テストの方法

Supabase SQL Editor でロール切り替え

-- 認証済みユーザー A としてクエリ実行
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claim.sub = 'user-A-uuid';

SELECT * FROM article_purchases;
-- → user_id = 'user-A-uuid' の行のみ表示される

curl での確認

# Anon Key で他人の購入を取得しようとする
curl "https://xxx.supabase.co/rest/v1/article_purchases?user_id=eq.OTHER_USER_ID" \
  -H "apikey: ANON_KEY" \
  -H "Authorization: Bearer USER_A_JWT"
# → 空の配列 []

RLS が効いていれば、他人のデータは取得できない。


本番運用チェックリスト

  • ALTER TABLE ... ENABLE ROW LEVEL SECURITY を全テーブルで実行
  • SELECT ポリシーを最小権限で定義
  • INSERT/UPDATE/DELETE は意図的にポリシーを作らないか、厳密に制限
  • Service Role Key は NEXT_PUBLIC_ をつけずに環境変数に保存
  • Service Role Key を使うコードはサーバー側のみ(API Route, Server Action, Webhook)
  • 複合インデックス + 部分インデックスを購入チェック用に作成
  • Supabase Dashboard で「Policies」タブを開き、全テーブルにRLSが有効か視覚的に確認

まとめ

RLS は「データベース層での防御」という発想の転換。

アプリケーション層のチェックをすり抜けても、データベース層で止まる。二重の防御が効くので、コードのバグや設定ミスがあってもデータが漏れない。

有料コンテンツを扱うなら、RLS は「あったら嬉しい」ではなく「必須」の機能だと考えるべき。

設計のコツは:

  1. デフォルト全拒否: RLSを有効化したら、最小限のポリシーから始める
  2. 書き込みは Service Role Key: クライアントから書き込ませない
  3. インデックスを忘れない: RLS でクエリが遅くなる原因の8割はインデックス不足

この記事の内容を実装すれば、有料コンテンツの「お金を払っていない人に見られる」リスクは限りなくゼロにできる。

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 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。

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