supabase-security-basics について
Supabase Security Basics は、Supabase プロジェクトの保護を支援するスキルです。API キーの分離、RLS ポリシー、API 強化を扱い、SQL と JavaScript の例を示します。
主な機能
- 01クライアント用とサーバー用のキー分離
- 02RLS ポリシーのパターンと監査
- 03API 強化チェックリスト
- 04SQL インジェクション対策の案内
- 05セキュリティエラーの診断
活用シーン
- 01public テーブルの RLS 適用状況の監査
- 02service role キーをサーバーコードに限定
- 03CORS とデータベース接続の強化
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ jeremylongshore/ tons-of-skills-marketplace --skill supabase-security-basicsインストール後、Claude Code で/supabase-security-basicsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/errors.mdexamples.mdimplementation.md詳細
Supabase セキュリティの基本
概要
Supabase は PostgREST を介して Postgres データベースをインターネットに直接公開します。Row Level Security が有効になっていないテーブルはすべて、プロジェクトの URL と anon キーの両方を知っている人なら誰でも、読み取りと書き込みが可能です。これらはいずれも公開されています。このスキルでは、Supabase セキュリティの3つの柱、つまりキーの分離(anon と service_role)、RLS ポリシーの適用、API サーフェスの強化について説明します。
前提条件
- Dashboard にアクセスできる Supabase プロジェクト(ローカルまたはホスト型)が作成されていること
@supabase/supabase-jsがインストールされていること(npm install @supabase/supabase-js)SUPABASE_URLとSUPABASE_ANON_KEYの環境変数が設定されていること- SQL と Postgres の基礎知識
手順
ステップ 1 — 2つの API キーについて理解する
Supabase はプロジェクトごとに2つのキーを発行します。両者を混同することが、最もよくあるセキュリティ上のミスです。
| キー | 環境変数 | クライアントに公開可能か | RLS の動作 |
|---|---|---|---|
| Anon キー | SUPABASE_ANON_KEY | はい — ブラウザーで安全 | すべての RLS ポリシーに従います |
| Service role キー | SUPABASE_SERVICE_ROLE_KEY | 絶対に公開しないでください | すべての RLS を回避します |
anon キーは JWT であり、PostgREST はこれを使って適用する RLS ポリシーを判断します。RLS ポリシーで明示的に許可されたデータにしかアクセスできないため、クライアント側のバンドルに含めても安全です。service role キーはすべての RLS ポリシーを回避するため、サーバー側のコード(API ルート、Edge Functions、cron ジョブ、マイグレーションスクリプト)にのみ配置してください。
// CORRECT: anon key on the client
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// CORRECT: service role key ONLY in server-side code
// e.g., app/api/admin/route.ts (Next.js server route)
import { createClient } from '@supabase/supabase-js'
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } }
)
// WRONG — service role key in client-side code
// This bypasses ALL RLS and leaks your admin key to every user
const supabase = createClient(url, process.env.NEXT_PUBLIC_SERVICE_ROLE_KEY!) // NEVER DO THIS
キーのローテーション: Dashboard > Settings > API でキーを再生成します。ローテーション後は、すべての環境変数を更新し、すべてのサービスを再デプロイしてください。古いキーは直ちに無効になり、猶予期間はありません。
ステップ 2 — すべてのテーブルで Row Level Security を適用する
RLS がなければ、public スキーマ内のテーブルはどれも、anon キーを持つ人なら誰でも REST API 経由で完全にアクセスできます。RLS は任意ではなく、主要なアクセス制御レイヤーです。
-- Audit: find tables missing RLS
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;
-- Enable RLS on every public table
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
-- CRITICAL: enabling RLS with NO policies blocks ALL access via the API.
-- You MUST add at least one policy per table per operation (SELECT, INSERT, UPDATE, DELETE).
ポリシーパターン — ユーザーが自身の行を読み書きする場合:
-- SELECT: user can only read their own rows
CREATE POLICY "Users read own data"
ON public.todos FOR SELECT
USING (auth.uid() = user_id);
-- INSERT: user can only insert rows for themselves
CREATE POLICY "Users insert own data"
ON public.todos FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- UPDATE: user can only update their own rows
CREATE POLICY "Users update own data"
ON public.todos FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- DELETE: user can only delete their own rows
CREATE POLICY "Users delete own data"
ON public.todos FOR DELETE
USING (auth.uid() = user_id);
ポリシーパターン — 読み取りは公開、書き込みは認証済みユーザーのみの場合:
CREATE POLICY "Anyone can read posts"
ON public.posts FOR SELECT
USING (true);
CREATE POLICY "Authenticated users can insert"
ON public.posts FOR INSERT
WITH CHECK (auth.uid() IS NOT NULL);
ポリシーパターン — カスタム JWT クレームによるロールベースのアクセス制御:
-- Admin-only policy using app_metadata
CREATE POLICY "Admins have full access"
ON public.settings FOR ALL
USING (
(auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'
);
サーバー側でカスタムクレームを設定するには:
// Server-side only — requires service role key
const { error } = await supabaseAdmin.auth.admin.updateUserById(userId, {
app_metadata: { role: 'admin' }
})
ポリシーパターン — 組織単位のアクセス制御:
CREATE POLICY "Org members can read projects"
ON public.projects FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.members
WHERE members.organization_id = projects.organization_id
AND members.user_id = auth.uid()
)
);
重要な違い — USING と WITH CHECK:
USING (expr)— ユーザーが参照できる既存の行を絞り込みます(SELECT、UPDATE、DELETE)WITH CHECK (expr)— 新規または変更された行のデータを検証します(INSERT、UPDATE)- UPDATE では両方が必要です。USING は対象にできる行を制御し、WITH CHECK は新しい値として許可される内容を制御します
ステップ 3 — API サーフェスを強化する
JWT の検証: Supabase はサーバー側で JWT を自動的に検証します。RLS ポリシー内の auth.uid() 関数は、検証済み JWT から認証済みユーザーの ID を取得します。RLS ポリシー内でトークンを手動で検証する必要はありません。Supabase が処理します。
SQL インジェクションの防止: Supabase JS SDK は内部でパラメーター化クエリを使用します。ユーザー入力から生の SQL 文字列を組み立てないでください。常に SDK のクエリビルダーを使用してください。
// SAFE: SDK parameterizes automatically
const { data } = await supabase
.from('posts')
.select('*')
.eq('author_id', userId)
.ilike('title', `%${searchTerm}%`)
// DANGEROUS: raw SQL with string interpolation
// Only use supabase.rpc() with parameterized functions, never template literals
ネットワーク制限: Dashboard > Settings > Database > Network Restrictions で、直接のデータベース接続を既知の IP 範囲に制限します。これは REST API(PostgREST 経由)には影響しませんが、Postgres への直接接続を保護します。
CORS の設定: Dashboard > Settings > API > CORS で、プロジェクトごとに許可するオリジンを設定します。デフォルトではすべてのオリジン(*)が許可されます。本番環境では自社のドメインに制限してください。
未使用の認証プロバイダーを無効にする: Dashboard > Authentication > Providers で、現在使用していないプロバイダー(メール、電話、Google、GitHub など)を無効にし、攻撃対象領域を減らします。
SSL の適用: Dashboard > Settings > Database > SSL Configuration で、すべての直接データベース接続に SSL を適用します。
ステートメントのタイムアウト: 長時間実行されるクエリによってデータベースリソースが枯渇するのを防ぎます。
ALTER ROLE authenticated SET statement_timeout = '10s';
ALTER ROLE anon SET statement_timeout = '5s';
デフォルトのスキーマ権限を取り消す(確認のみ):
-- Supabase handles this by default, but verify:
-- anon and authenticated roles should only access data through RLS policies
SELECT grantee, privilege_type, table_name
FROM information_schema.role_table_grants
WHERE table_schema = 'public'
AND grantee IN ('anon', 'authenticated')
ORDER BY table_name, grantee;
出力
これらの手順を完了すると、次の状態になります。
- クライアント側のコードでのみ使用する Anon キーと、サーバー側のみに制限されたサービスロールキー
- すべてのパブリックテーブルで RLS が有効になり、操作ごとに明示的なポリシーが設定されている
- ロールベースのアクセスパターンに対応するカスタム JWT クレームが設定されている
- ネットワーク制限、CORS、SSL、ステートメントタイムアウトが強化されている
- 未使用の認証プロバイダーが無効になっている
セキュリティ監査チェックリスト
- すべてのパブリックテーブルで RLS が有効になっている(
SELECT rowsecurity FROM pg_tables WHERE schemaname='public') - 各テーブルに、必要な操作ごとに少なくとも1つの RLS ポリシーが設定されている
- サービスロールキーが、クライアント側や
NEXT_PUBLIC_*環境変数に含まれていない -
.envファイルが.gitignoreに含まれている - メール確認が有効になっている(Dashboard > Authentication > Settings)
- OAuth リダイレクト URL が自分のドメインに制限されている
- 未使用の認証プロバイダーが無効になっている
- SSL の強制が有効になっている(Dashboard > Database > SSL)
- データベースのパスワードがデフォルトから変更されている
- 直接 DB 接続に対するネットワーク制限が設定されている
-
authenticatedロールとanonロールにstatement_timeoutが設定されている - 機密性の高いユーザー操作で MFA が有効になっている
- 本番環境でポイントインタイムリカバリー(PITR)が有効になっている
- 漏洩の疑いがある場合、API キーがローテーションされている
エラー処理
| エラー | 原因 | 解決方法 |
|---|---|---|
42501: new row violates row-level security policy | RLS ポリシーがない、または WITH CHECK 条件に失敗している | その操作に対する RLS ポリシーを追加または修正し、auth.uid() が行のユーザー列と一致することを確認します |
クエリがエラーなしで空の data を返す | RLS の USING 句によってすべての行が除外されている | ポリシー内の auth.uid() が認証済みユーザーと一致することを確認し、JWT クレームを確認します |
PGRST301: JWSError | JWT トークンが無効または期限切れになっている | ユーザーを再認証し、SUPABASE_ANON_KEY がプロジェクトと一致することを確認します |
PGRST302: anonymous access disabled | クライアントの初期化時に Anon キーが渡されていない | Anon キーを createClient() に渡し、環境変数が設定されていることを確認します |
permission denied for table X | RLS が有効だが、一致するポリシーがない | 特定の操作(SELECT/INSERT/UPDATE/DELETE)に対するポリシーを作成します |
Could not find the function auth.uid() | PostgREST コンテキスト外で SQL を実行している | auth.uid() は PostgREST が評価する RLS ポリシー内でのみ動作します。マイグレーションでは明示的なユーザー ID を使用します |
例
新しいテーブルの最小限のセキュアなセットアップ:
-- 1. Create table
CREATE TABLE public.notes (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) NOT NULL DEFAULT auth.uid(),
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 2. Enable RLS immediately
ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY;
-- 3. Add policies
CREATE POLICY "Users manage own notes" ON public.notes
FOR ALL USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
クライアント側のクエリ(Anon キー — RLS による強制):
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// This only returns notes belonging to the authenticated user
// because the RLS policy filters by auth.uid()
const { data: notes, error } = await supabase
.from('notes')
.select('*')
.order('created_at', { ascending: false })
リソース
次のステップ
supabase-prod-checklistを使って本番環境向けの強化を適用しますsupabase-auth-flowsを使って認証フローを設定しますsupabase-migrationsを使ってデータベースのマイグレーションを設定します