Supabase 認証・RLS 設計の実務ガイド
Supabase の Row Level Security を使った安全なデータアクセス制御の実装方法を解説。認証フロー・ポリシー設計・マルチテナント対応まで、受託開発で即使える実践ガイド。

Supabase 認証・RLS 設計の実務ガイド
Supabase を使えば、PostgreSQL の Row Level Security (RLS) と組み合わせて、最小限のコードで安全なマルチユーザーアプリケーションを構築できます。しかし、認証フローや RLS ポリシーの設計を誤ると、データ漏洩や権限エスカレーションのリスクが生まれます。
本記事では、受託開発・自社サービスの現場で Supabase を採用する際に押さえておきたい認証設計とRLS ポリシーの実装パターンを、コード例・チェックリスト・よくある失敗例とともに整理します。
1. Supabase 認証の基本構成
認証フローの全体像
Supabase は PostgreSQL の拡張として GoTrue を提供し、以下の認証方式をサポートします。
- Email/Password 認証
- Magic Link(パスワードレス)
- OAuth(Google / GitHub / Azure AD など)
- Phone(SMS 認証)
内部的には JWT (JSON Web Token) を発行し、クライアントは Authorization: Bearer <token> ヘッダーで API リクエストを行います。Supabase の PostgreSQL 拡張は、この JWT を自動的に auth.uid() として RLS ポリシー内で参照可能にします。
必須設定項目
| 項目 | 設定場所 | 推奨値 | |------|----------|--------| | JWT 有効期限 | Dashboard → Authentication → Settings | 3600 秒(1 時間) | | Email 確認 | Email Templates | 本番環境では必須 ON | | Redirect URLs | Authentication → URL Configuration | 本番・ステージング・ローカルをホワイトリスト登録 | | Rate Limiting | API Settings | デフォルト(60 req/min)を確認 |
2. Next.js での認証実装パターン
クライアント側の認証フック
Next.js App Router で Supabase を使う場合、@supabase/ssr パッケージを使います。
// app/auth/supabase-provider.tsx
'use client'
import { createBrowserClient } from '@supabase/ssr'
import { useRouter } from 'next/navigation'
import { createContext, useContext, useEffect, useState } from 'react'
type SupabaseContext = {
supabase: ReturnType<typeof createBrowserClient>
}
const Context = createContext<SupabaseContext | undefined>(undefined)
export default function SupabaseProvider({
children,
}: {
children: React.ReactNode
}) {
const [supabase] = useState(() =>
createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
)
const router = useRouter()
useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(() => {
router.refresh()
})
return () => {
subscription.unsubscribe()
}
}, [supabase, router])
return (
<Context.Provider value={{ supabase }}>
{children}
</Context.Provider>
)
}
export const useSupabase = () => {
const context = useContext(Context)
if (context === undefined) {
throw new Error('useSupabase must be used inside SupabaseProvider')
}
return context
}
サーバー側での認証チェック(middleware)
// middleware.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({ name, value, ...options })
response = NextResponse.next({
request: { headers: request.headers },
})
response.cookies.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
request.cookies.set({ name, value: '', ...options })
response = NextResponse.next({
request: { headers: request.headers },
})
response.cookies.set({ name, value: '', ...options })
},
},
}
)
const { data: { user } } = await supabase.auth.getUser()
// 未認証ユーザーを /login にリダイレクト
if (!user && !request.nextUrl.pathname.startsWith('/login')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'],
}
3. Row Level Security (RLS) の設計原則
RLS を有効化する理由
Supabase の RLS を使わない場合、anon キーで直接 PostgreSQL にアクセスできるため、クライアント側で SQL インジェクションや権限昇格のリスクが生まれます。RLS を有効化すると、すべての SELECT / INSERT / UPDATE / DELETE が PostgreSQL レベルで検証されます。
基本の RLS ポリシー
-- 1. テーブルに RLS を有効化
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- 2. 自分の投稿だけ SELECT できるポリシー
CREATE POLICY "Users can view own posts"
ON posts
FOR SELECT
USING (auth.uid() = user_id);
-- 3. 自分の投稿だけ INSERT できるポリシー
CREATE POLICY "Users can insert own posts"
ON posts
FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- 4. 自分の投稿だけ UPDATE できるポリシー
CREATE POLICY "Users can update own posts"
ON posts
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- 5. 自分の投稿だけ DELETE できるポリシー
CREATE POLICY "Users can delete own posts"
ON posts
FOR DELETE
USING (auth.uid() = user_id);
RLS ポリシー設計のチェックリスト
| 項目 | 確認内容 |
|------|----------|
| ✅ すべてのテーブルで RLS を有効化 | ALTER TABLE ... ENABLE ROW LEVEL SECURITY |
| ✅ anon ロールのポリシーを定義 | CREATE POLICY ... TO anon または TO authenticated |
| ✅ service_role キーは使わない | クライアントには絶対に渡さず、サーバー専用に |
| ✅ auth.uid() の NULL チェック | 未認証時は auth.uid() IS NOT NULL で弾く |
| ✅ 複合条件は AND で明示 | auth.uid() = user_id AND status = 'published' |
| ✅ WITH CHECK で INSERT/UPDATE を制限 | USING は SELECT、WITH CHECK は変更時の検証 |
4. マルチテナント SaaS の RLS 設計
組織ベースのアクセス制御
受託案件や SaaS でよくあるのが「ユーザーが複数の組織(organization)に所属し、組織ごとにデータを分離する」パターンです。
-- テーブル設計例
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE organization_members (
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT CHECK (role IN ('owner', 'admin', 'member')),
PRIMARY KEY (organization_id, user_id)
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- RLS ポリシー:自分が所属する組織の project だけ見える
CREATE POLICY "Members can view org projects"
ON projects
FOR SELECT
USING (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid()
)
);
-- 管理者だけが UPDATE できる例
CREATE POLICY "Admins can update org projects"
ON projects
FOR UPDATE
USING (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid()
AND role IN ('owner', 'admin')
)
);
パフォーマンス最適化のポイント
RLS ポリシー内のサブクエリは毎回実行されるため、複雑な JOIN が入ると遅くなります。
対策:
organization_membersに(user_id, organization_id)の複合インデックスを張る- 頻繁にアクセスする「現在の組織 ID」を JWT のカスタムクレームに入れる(後述)
CREATE INDEX idx_org_members_user ON organization_members(user_id, organization_id);
5. JWT カスタムクレームで RLS を高速化
カスタムクレームとは
Supabase の JWT には sub(ユーザー ID)以外にも、app_metadata や user_metadata を埋め込めます。これを使えば、「現在選択中の組織 ID」を JWT に含め、サブクエリを避けられます。
Database Function で JWT にクレームを追加
-- ユーザーのメタデータに current_org_id を保存
CREATE OR REPLACE FUNCTION set_current_organization(org_id UUID)
RETURNS VOID AS $
BEGIN
UPDATE auth.users
SET raw_user_meta_data = jsonb_set(
COALESCE(raw_user_meta_data, '{}'),
'{current_org_id}',
to_jsonb(org_id)
)
WHERE id = auth.uid();
END;
$ LANGUAGE plpgsql SECURITY DEFINER;
-- RLS ポリシーで JWT のクレームを参照
CREATE POLICY "View projects in current org"
ON projects
FOR SELECT
USING (
organization_id = (auth.jwt() -> 'user_metadata' ->> 'current_org_id')::UUID
);
クライアント側での組織切り替え
// 組織を切り替える
async function switchOrganization(orgId: string) {
const { error } = await supabase.rpc('set_current_organization', {
org_id: orgId,
})
if (!error) {
// JWT を再取得(メタデータが反映される)
await supabase.auth.refreshSession()
}
}
6. よくある失敗パターンと対策
失敗例 1:RLS を有効化したが、ポリシーが空
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- ポリシーを定義しないと、anon ロールは何も見えない
対策:最低限の SELECT ポリシーを必ず定義する。
失敗例 2:service_role キーをクライアントで使用
// ❌ 絶対にダメ!
const supabase = createClient(URL, SERVICE_ROLE_KEY)
対策:service_role は RLS をバイパスするため、サーバーサイド(API Routes / Edge Functions)専用にする。
失敗例 3:auth.uid() が NULL の場合を考慮しない
-- 未認証ユーザーも通ってしまう
CREATE POLICY "Bad policy"
ON posts
FOR SELECT
USING (user_id = auth.uid()); -- auth.uid() が NULL なら全件通る可能性
対策:
CREATE POLICY "Correct policy"
ON posts
FOR SELECT
USING (auth.uid() IS NOT NULL AND user_id = auth.uid());
失敗例 4:RLS ポリシーのテストを忘れる
RLS は「書いて終わり」ではなく、実際に別のユーザーでログインしてデータが見えないか確認する必要があります。
対策:
-- テスト用ユーザーで SELECT を実行
SET ROLE anon;
SET request.jwt.claim.sub = 'test-user-uuid';
SELECT * FROM posts; -- 期待通り絞り込まれるか確認
RESET ROLE;
7. 実務で使える RLS パターン集
パターン A:公開データは全員が読める
CREATE POLICY "Public posts are viewable by everyone"
ON posts
FOR SELECT
USING (status = 'published');
パターン B:作成者 + 管理者だけが編集可能
CREATE POLICY "Owners and admins can update"
ON posts
FOR UPDATE
USING (
user_id = auth.uid()
OR EXISTS (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
パターン C:チームメンバー全員が閲覧可能
CREATE POLICY "Team members can view"
ON documents
FOR SELECT
USING (
team_id IN (
SELECT team_id FROM team_members WHERE user_id = auth.uid()
)
);
パターン D:期限付きアクセス(有効期限チェック)
CREATE POLICY "Time-limited access"
ON subscriptions
FOR SELECT
USING (
user_id = auth.uid()
AND expires_at > NOW()
);
8. まとめ
Supabase の認証と RLS を正しく設計すれば、以下のメリットが得られます。
- 最小限のバックエンドコード:認証・認可ロジックを PostgreSQL レベルで完結
- セキュリティの担保:クライアント側の改ざんに強い
- マルチテナント対応:組織・チーム単位のデータ分離が容易
一方で、以下の点に注意が必要です。
- RLS ポリシーは必ずテストする(別ユーザーでログインして確認)
service_roleキーはクライアントに絶対渡さない- 複雑なサブクエリは JWT カスタムクレームで最適化
- インデックス設計を忘れない(
organization_membersなど)
本記事で紹介したパターンは、受託開発・スタートアップの MVP から本番運用まで幅広く使えます。Supabase を採用する際は、ぜひ本チェックリストを参考にしてください。
Yureate では、Supabase を活用した MVP 開発・技術選定支援を行っています。
認証設計・RLS ポリシーのレビュー、パフォーマンス改善などのご相談がありましたら、お気軽にお問い合わせください。
