Supabase RLSで「全ユーザーのデータが見える」——設定ミスが引き起こす情報漏洩の怖さ
ユーザー認証を実装した。ダッシュボードにそのユーザーのデータを表示する機能を作った。動作確認した。
別のテストアカウントでログインして確認したら、最初のテストアカウントのデータも全部見えた。
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がチャプターを自動生成。
YouTubeのURLをコピーして貼る
「生成する」を押す
概要欄にコピペして完了
月3回まで無料 · クレジットカード不要