5/13 ローンチ予定!
PiloTube

PiloTube 開発日誌

← 「ひとり社長のAI開発記」一覧へ

Supabase RLSで「全ユーザーのデータが見える」——設定ミスが引き起こす情報漏洩の怖さ

約13分で読めます
SupabaseRLSセキュリティRow Level Security開発秘話

ユーザー認証を実装した。ダッシュボードにそのユーザーのデータを表示する機能を作った。動作確認した。

別のテストアカウントでログインして確認したら、最初のテストアカウントのデータも全部見えた。

Supabaseを使い始めたとき、「RLSを有効にした」だけで安全だと思い込んでいた。この思い込みが危険だった。


Supabase RLSとは何か

RLS(Row Level Security)はPostgreSQLの機能で、テーブルの各行へのアクセスをユーザー単位で制御できる。

たとえばpostsテーブルに複数ユーザーのデータが入っていても、ログイン中のユーザーは自分の投稿しか取得・更新・削除できないようにする、といった制御だ。

Supabaseではこれをポリシーというルールで設定する。

RLSの状態は3種類ある

ここを最初に理解していないと詰まる。

| 状態 | 挙動 | |------|------| | RLS無効 | 誰でも全データにアクセスできる(デフォルト) | | RLS有効 + ポリシーなし | 誰もデータにアクセスできない | | RLS有効 + ポリシーあり | ポリシーの条件に従ってアクセス制御される |

「RLSを有効にした」だけではポリシーがない状態になる。

ポリシーがないとデータが取れないため、「あれ、データが取得できない」とバグだと思ってRLSを無効に戻してしまうことがある。そのまま本番デプロイすると全データが丸見えになる。


ハマりポイント1: RLS有効化とポリシー設定は別の操作

Supabaseのダッシュボードで「Enable RLS」ボタンを押すと、RLSは有効になる。しかしポリシーはまだ何もない状態だ。

ポリシーを設定するには、別途「Add Policies」からルールを書く必要がある。

最もよく使う基本ポリシー:

-- ログインユーザーが自分のデータだけ読める
CREATE POLICY "Users can read own data"
ON public.posts
FOR SELECT
USING (auth.uid() = user_id);

-- ログインユーザーが自分のデータだけ作成できる
CREATE POLICY "Users can insert own data"
ON public.posts
FOR INSERT
WITH CHECK (auth.uid() = user_id);

-- ログインユーザーが自分のデータだけ更新できる
CREATE POLICY "Users can update own data"
ON public.posts
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);

-- ログインユーザーが自分のデータだけ削除できる
CREATE POLICY "Users can delete own data"
ON public.posts
FOR DELETE
USING (auth.uid() = user_id);

auth.uid()はSupabaseが提供する関数で、現在認証しているユーザーのUIDを返す。user_idはテーブルの列名(ユーザーIDを保存するカラム)。


ハマりポイント2: INSERT時のWITH CHECKを忘れる

SELECT用のポリシーだけ書いて、INSERT用を書き忘れるパターンがある。

RLSはデフォルト拒否なので、INSERT用ポリシーがないとデータを作成できない。「作成ボタンを押しても何も起きない」という症状が出る。

さらに気をつけたいのがWITH CHECKの書き方だ。

-- 不完全な例
CREATE POLICY "Users can insert"
ON public.posts
FOR INSERT
WITH CHECK (true); -- ← 誰でも挿入できてしまう

WITH CHECK (true)は「条件なし = 誰でもOK」という意味になる。慌てて書くと、認証チェックをすっ飛ばした穴あきポリシーができあがる。

正しくはこう書く:

CREATE POLICY "Users can insert own data"
ON public.posts
FOR INSERT
WITH CHECK (auth.uid() = user_id);

これで「自分のuser_idを持つ行しか挿入できない」という制約になる。


ハマりポイント3: service_roleキーを使うとRLSが完全にバイパスされる

Supabaseには2種類のAPIキーがある。

| キー | 用途 | RLS | |------|------|-----| | anonキー | フロントエンド | RLSの制御を受ける | | service_roleキー | バックエンド管理用 | RLSをバイパスする |

service_roleキーは文字通り「サービスロール」として動作し、全データにアクセスできる。バックエンドのサーバーサイド処理(一括データ取得、管理者用操作など)のために使う。

問題はフロントエンドにservice_roleキーをうっかり使ってしまうケースだ。

// NG: service_roleキーをフロントエンドで使う
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!, // ← これは絶対NG
);

NEXT_PUBLIC_のプレフィックスをつけると、Next.jsはその値をクライアントサイドのバンドルに含める。ブラウザでページのJavaScriptを見ると、service_roleキーが丸見えになる。

このキーを持っていれば、ブラウザのコンソールから全データにアクセスできてしまう。

ルール:

  • フロントエンドにはNEXT_PUBLIC_SUPABASE_ANON_KEYのみ使う
  • service_roleキーはサーバーサイドのみ、NEXT_PUBLIC_プレフィックスなしで管理する

ハマりポイント4: テーブルを増やすたびにRLSの設定を忘れる

新しいテーブルを追加するたびに、RLS有効化 + ポリシー設定を忘れやすい。

開発の流れでは「とりあえずテーブルを作ってデータ確認」という作業が発生する。このとき、RLSなしで作ってそのまま進んでしまうことがある。

Supabaseのダッシュボードで確認できるが、テーブルの数が増えると見落としが起きる。

SQLで一覧確認する方法:

-- RLSが無効のテーブルを確認する
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
AND rowsecurity = false;

このクエリでRLS無効のテーブルが一覧できる。デプロイ前に実行する習慣をつけると、設定漏れを防げる。


ハマりポイント5: JWTトークンの検証をAPIで忘れる

フロントエンドでSupabase Authを使っていると、ログイン後にJWTトークンが発行される。このトークンをバックエンドAPIに渡して、バックエンド側でもユーザーを認証する必要がある。

「フロントエンドでSupabaseの認証が通っているんだから、バックエンドは信じていいだろう」という思い込みが危険。

# NG: トークン検証なし
@router.get("/api/user/data")
async def get_user_data(user_id: str):
    # user_idをクエリパラメータで受け取っているだけ
    # → 任意のuser_idで他ユーザーのデータを取得できる
    return await db.get_data_by_user_id(user_id)
# OK: JWTトークンを検証してからuser_idを取得
from supabase import create_client

supabase = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)

@router.get("/api/user/data")
async def get_user_data(authorization: str = Header(...)):
    token = authorization.replace("Bearer ", "")

    # Supabaseでトークンを検証し、ユーザー情報を取得
    user_response = supabase.auth.get_user(token)
    if not user_response.user:
        raise HTTPException(status_code=401, detail="Invalid token")

    user_id = user_response.user.id
    # 検証済みのuser_idを使ってデータを取得
    return await db.get_data_by_user_id(user_id)

フロントエンドの認証とバックエンドの認証は独立して実装する。フロントがSupabase Authで認証済みでも、バックエンドは「誰から来たリクエストか」を自分でトークン検証で確認する必要がある。


実際の設計パターン

上記のハマりポイントを踏まえた、安全な設計の基本パターン。

テーブル設計

全てのユーザーデータテーブルにはuser_id列を持たせる。

CREATE TABLE public.user_content (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  content TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- RLS有効化
ALTER TABLE public.user_content ENABLE ROW LEVEL SECURITY;

-- ポリシー設定
CREATE POLICY "Users can manage own content"
ON public.user_content
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);

ON DELETE CASCADEをつけると、ユーザーアカウントが削除されたとき関連データも自動で削除される。

フロントエンドのクライアント設定

// lib/supabase/client.ts(ブラウザ用)
import { createBrowserClient } from '@supabase/ssr';

export const createClient = () =>
  createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // anon keyのみ
  );

// lib/supabase/server.ts(サーバーサイド用)
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export const createClient = () => {
  const cookieStore = cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    { cookies: { getAll: () => cookieStore.getAll() } }
  );
};

サーバーサイドでもanonキーを使い、認証済みユーザーのセッションをCookieから読み込む形にする。service_roleキーはDB管理操作が必要なバックエンドAPIのみ。


RLSが正しく動いているか確認する方法

実装後に、実際に別ユーザーのデータが取得できないかテストする。

// テスト用: 別ユーザーのIDでデータ取得を試みる
const { data, error } = await supabase
  .from('user_content')
  .select('*')
  .eq('user_id', otherUserId); // 別ユーザーのID

console.log(data); // RLSが効いていれば [] (空配列)
console.log(error); // エラーにはならず、空配列が返る

RLSが正しく設定されていれば、他のユーザーのIDで検索しても空配列が返る。エラーにはならない(403が出るわけではない)。この挙動を知っていないと「空配列 = バグ」と思い込んでしまう。


デプロイ前チェックリスト

| チェック項目 | 確認方法 | |------------|---------| | 全テーブルのRLS有効化 | SELECT * FROM pg_tables WHERE rowsecurity = false | | 全テーブルにポリシー設定 | ダッシュボード > Authentication > Policies | | フロントエンドにanon keyのみ使用 | 環境変数にservice_role keyがNEXT_PUBLIC_ではないか確認 | | バックエンドAPIのJWT検証 | 全エンドポイントのauth.get_user()呼び出し確認 | | 別ユーザーのデータが取れないかテスト | テストアカウント2つで動作確認 |


まとめ

Supabase RLSは「有効にするだけ」では機能しない。RLS有効化 + ポリシー設定の2ステップが必要で、どちらかが抜けると穴になる。

また、RLSはデータベース層のセキュリティであり、APIエンドポイントのJWT検証とは別の話だ。両方を適切に設定して初めて、「ログインユーザーは自分のデータだけ触れる」という状態になる。

最初に「全ユーザーのデータが見えた」あの瞬間は本当に焦った。ローカルのテストデータだったから実害はなかったが、本番環境で起きていたら取り返しがつかない。RLSの設定は早めに、テーブルを作るたびに忘れずに。

チャプター生成AI

URL貼るだけ。AIがチャプターを自動生成。

1

YouTubeのURLをコピーして貼る

2

「生成する」を押す

3

概要欄にコピペして完了

無料でチャプターを生成する →

月3回まで無料 · クレジットカード不要