はじめに
Supabase × Next.js の組み合わせは、個人開発や小規模プロダクトにおいて非常に強力です。
認証・DB・RLS(Row Level Security)が一体化しており、「正しく使えば」かなり安全な構成を簡単に作れます。
ただし、そのRLSが本当に分かりづらい。
- SQLは成功しているのに select が空配列になる
- insert はできるのに select できない
- Service Role Key を使うと全部動く
- anon key に戻すと全部壊れる
今回は、Supabase × Next.js で実際にハマったRLS設計の落とし穴と、
「どう考えると破綻しないか」を整理します。
RLSは「誰が」「どこから」叩くかを最初に決めないと破綻する
SupabaseのRLSで一番重要なのは、このクエリは「誰として」「どこから」実行されるのかを最初に決め切ることです。
ここが曖昧なまま進めると、
- policy を足しても直らない
- なぜ通らないか分からない
- 最終的に service_role に逃げる
という負のループに入ります。
Supabase RLSで混乱する理由
理由①:認証は通っているのに、データが取れない
Supabaseでは、
認証(auth)
権限(RLS)
が完全に別レイヤーです。
そのため、
const { data: { user } } = await supabase.auth.getUser()
で user が取れても、
const { data } = await supabase.from("workouts").select("*")
が 空配列になることがあります。
これは RLSが拒否しているが、エラーにはならないためです。
理由②:Service Role Key が「万能すぎる」
Next.js で Server Actions や API Route を書いていると、
createClient(process.env.SUPABASE_SERVICE_ROLE_KEY)
を使えば すべてのRLSを無視できます。
これにより、
「動いた!」
「RLSなんか要らないのでは?」
という錯覚に陥ります。
しかしこれは 管理者として直DBを叩いている状態であり、フロントエンドや将来の拡張を考えると非常に危険です。
実際にハマった構成
前提
- Next.js App Router
- Supabase Auth 使用
- テーブル:user_plan_progress
user_id uuid not null
plan_type text
completed_sessions int
やりたかったこと
- ログインユーザーが
- 自分のデータだけ
- select / insert / update できる
失敗したRLS例
よくある失敗①:insert だけ許可して select を忘れる
create policy "allow insert"
on user_plan_progress
for insert
with check ( auth.uid() = user_id );
この状態で insert は成功します。
しかし select は 常に空配列になります。
理由は単純で、select用のpolicyが存在しないからです。
よくある失敗②:auth.uid() が null になる
Next.js Server Component / Server Action から anon key で叩くと、
auth.uid()
は null になります。
その結果、
auth.uid() = user_id
は常に false になり、RLSに弾かれます。
「ログインしているのに null?」となる原因は、
- Cookie が渡っていない
- createClient の設定ミス
- server / client の役割混同
がほとんどです。
正しく考えるRLS設計の順序
ここからが一番重要です。
① クエリの実行主体を分ける
まず、以下を分離して考えます。
| 実行場所 | 使用キー | RLS |
|---|---|---|
| ブラウザ | anon key | 有効 |
| Server Actions | anon key + cookie | 有効 |
| 管理処理 | service role | 無効 |
「service role を使う場所」を最小限にするのが鉄則です。
② RLSはCRUDすべてを書く
最低限、以下は揃えます。
-- select
create policy "select own rows"
on user_plan_progress
for select
using ( auth.uid() = user_id );
-- insert
create policy "insert own rows"
on user_plan_progress
for insert
with check ( auth.uid() = user_id );
-- update
create policy "update own rows"
on user_plan_progress
for update
using ( auth.uid() = user_id );
「insertできたからOK」は 罠 です。
③ user_id は必ずアプリ側で明示的に渡す
const user = await supabase.auth.getUser()
await supabase.from("user_plan_progress").insert({
user_id: user.data.user.id,
plan_type: "SQUAT",
completed_sessions: 0
})
DB側で auth.uid() を自動セットしようとすると、
デバッグが一気に難しくなります。
RLSで詰まったときのチェックリスト
最後に、詰まったときの確認ポイントです。
- select 用 policy はあるか
- auth.uid() が null になっていないか
- Service Role Key で誤魔化していないか
- server / client で createClient を使い分けているか
- 「誰として実行しているか」を説明できるか
- これが説明できない状態は、だいたい壊れています。
まとめ
SupabaseのRLSは、強力・安全ですが、思想理解が必須です。
「とりあえず動かす」フェーズでは邪魔に感じますが、最初に設計を間違えると、後から必ず地獄を見る仕組みでもあります。
RLSはSQLではなく、アプリケーション設計そのものだと割り切るのが一番の近道でした。
同じところでハマる人の助けになれば幸いです。