← ブログ一覧

Supabase 認証・RLS 設計の実務ガイド

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

#データベース#PostgreSQL#セキュリティ#技術解説
Supabase 認証・RLS 設計の実務ガイド

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_metadatauser_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 ポリシーのレビュー、パフォーマンス改善などのご相談がありましたら、お気軽にお問い合わせください。

この内容について相談する他の記事を見る