はじめに
🤔「管理画面からも全データを見たい。RLS にも『管理者なら全部見える』ポリシーを書くの…?」
😵「テーブルが増えるたびに、管理者用のポリシーも増えていく…」
😰「でも RLS をバイパスするのって、なんだか怖い…」
Supabase で、ユーザー向けのアプリと運営用の管理画面を作るとき、こんな迷いはありませんか?🤔
自分が個人開発している食の口コミサービス「あじぴた」では、同じ DB に対して、アプリと管理画面でまったく違うアクセスの仕方をしています🔑
| アプリ本体 | 管理画面 | |
|---|---|---|
| DB へのアクセス | API 層を経由する | 管理者権限で直接 |
| 守り方 | RLS で行ごとに制御 | RLS をバイパスして、入口を絞る |
この記事では、なぜこう分けたのか、そしてRLS を外した分をどう補っているかを書きます🛡️
結論:相手を信頼できるかどうかで、DB の扱い方を変える
先に結論です👇
信頼できない相手(ブラウザ)には、RLS で守る。
信頼できる相手(運営スタッフ)には、RLS を外して、入れる人を絞る。
ここで大事なのが、タイトルにした考え方です。☝️
「全部見せるための RLS」を書くなら、RLS を使う意味がない。
管理画面は、運営スタッフが全データを見るための画面です。
そこに「管理者なら全部見える」というポリシーを書いても、行ごとに制御するという RLS の役割は果たしていません🙅
それなら最初からバイパスして、誰が入れるかを絞るほうが素直です。
**RLS(Row Level Security)**は、PostgreSQL の「行ごとに、誰が読み書きできるかを DB 側で決める」仕組みです。
Supabase では、ブラウザから DB を直接扱える代わりに、RLS で守るのが基本になっています。
前提:アプリと管理画面を、別のリポジトリにしている
あじぴたは、アプリ本体と管理画面を別のリポジトリで作っています📦
| リポジトリ | 役割 | 使う人 |
|---|---|---|
| アプリ本体 | ユーザーが使う Web アプリ | 一般のユーザー |
| 管理画面 | 通報の対応・お知らせ配信など | 運営スタッフだけ |
| サービスサイト | アプリへの入口になる LP | 一般の訪問者 |
アプリ本体と管理画面は、同じ Supabase の DBにつながっています。🔗
(サービスサイトは静的なページが中心で、DB は使っていません)
管理画面を別にしたことで、アプリ本体は一般ユーザーのことだけ考えればよくなりました✨
同じリポジトリに入れると、「一般ユーザーには見せない画面」を RLS と画面の両方で守ることになり、どちらも複雑になります。😵
ほかのリポジトリ(Chrome 拡張や、運用・記事の管理用)も含めた全体像は、ハブ記事にまとめています。
アプリ側:API 層 + RLS で守る
ブラウザは「信頼できない相手」
アプリ本体の相手は、ブラウザから来るリクエストです。
リクエストはいくらでも書き換えられるので、疑ってかかる必要があります😇
そこで、DB 側で行ごとの権限を強制する RLS を最後の砦にしています。
アプリのコードにバグがあっても、DB が最後に止めてくれる状態を作りたいからです🛡️
あじぴたには、本人以外には絶対に見せてはいけないデータがあります。
「自分には合わなかった」という評価で、これは誰にも公開しない設計です🔒
こういうデータがあるからこそ、アプリのコードだけに守りを任せないようにしています。
DB へのアクセスは API 層に一本化する
さらに、ブラウザから Supabase を直接呼ぶのはやめて、Next.js の API 層を経由する形にそろえました(いわゆる BFF)。
ブラウザ(Client Component)
→ /api/...(API ルート:ログイン確認・入力チェックだけ)
→ データ処理の関数(DB へのアクセスはここにだけ書く)
→ Supabase(ログインしたユーザーとして接続するので、RLS が効く)
Server Component
→ データ処理の関数を直接呼ぶ(自分の API を HTTP で呼び直さない)
ポイントは 2 つです☝️
1. ログインしたユーザーとして DB につなぐ。
API 層を挟んでも、DB にはそのユーザーとして接続しています。
なので RLS はそのまま効きます。API 層と RLS の二重の守りです。🛡️
2. 処理を書く場所は 1 か所、呼び出し方は 2 通り。
Server Component が自分の API を HTTP で呼び直すと、同じサーバーの中で無駄な通信が増えます。
そこで、処理は「データ処理の関数」に 1 か所だけ書き、呼び出し方を分けました。✂️
| 呼び出し元 | 呼び出し方 |
|---|---|
| Server Component | 関数を直接呼ぶ |
| Client Component |
/api を fetch で呼ぶ |
| 将来のスマホアプリ | 同じ /api を呼ぶ |
// データ処理の関数:DB へのアクセスはここにだけ書く
// 'server-only' で、ブラウザ側に紛れ込んだらビルドが失敗するようにしている
import 'server-only'
export const getMyReviews = async (supabase: SupabaseClient) => {
// ログインしたユーザーとして接続したクライアントを受け取るので、RLS が効く
const { data, error } = await supabase.from('reviews').select('...')
if (error) throw error
return data
}
// API ルート:ログイン確認・入力チェック・エラーの整形だけを担う
export async function GET() {
const supabase = await createClient() // ログインしたユーザーとして接続
const user = await requireUser(supabase)
if (!user) return unauthorized()
return Response.json(await getMyReviews(supabase))
}
管理画面側:「全部見せるための RLS」を書かない
運営スタッフは「信頼できる相手」
管理画面の相手は、運営スタッフです。
そして運営スタッフは、全データを見る必要があります👀
- 通報された口コミを確認する
- ユーザーの状態を調べる
- お知らせを配信する
ここに RLS を敷くと、どうなるでしょうか。🤔
-- 管理者なら全部見える、というポリシー(あじぴたでは書いていない)
create policy "admins can read all reviews" on reviews
for select using (is_admin());
これをテーブルの数だけ書くことになります😵
しかも中身は全部「管理者なら全部 OK」で、行ごとに制御するという RLS の役割は果たしていません。
だから、バイパスして入口を絞る
そこで管理画面は、RLS をバイパスする管理者権限で DB につないでいます🔑
Supabase でいう service_role です。🗝️
その代わり、誰が管理画面に入れるかを厳しく絞ります。🚪
| アプリ本体 | 管理画面 | |
|---|---|---|
| 相手 | ブラウザ(信頼できない) | 運営スタッフ(信頼できる) |
| DB への接続 | ログインしたユーザーとして | 管理者権限(service_role) |
| 守り方 | RLS で行ごとに制御 | 入れる人を絞る |
| アカウント | サービスのユーザー | 運営スタッフ専用のアカウント(サービスとは完全に別) |
運営スタッフのアカウントは、サービスのユーザーとは完全に別の仕組みにしています。
サービスのユーザーがどれだけ増えても、管理画面に入れる人は増えません👍
RLS を外した分、入口を厚くする
RLS という守りを外した以上、入口側で担保する必要があります。
管理画面が突破されたら、被害は全データに及ぶからです😱
認証を何重にもする
管理画面は、認証を 2 段構えにしています。🔒
- サイト全体の簡易ゲート:そもそも管理画面にたどり着かせない
- 運営スタッフのログイン:管理画面と、管理用の API を守る本命の認証
さらに、各処理の入口でも、個別にログイン状態を確認しています。
入口のチェックをすり抜けても、処理の側で止まる構造です🛡️
公開する URL を本番だけにする
ホスティング(Netlify)のプレビューデプロイとブランチデプロイを無効化しています。
これらは、認証が無い状態の公開 URL を自動で作ってしまうからです⚠️
また、Netlify の無料枠にはサイト全体をパスワードで守る機能がありません(上位プランの機能です)。
なので、1 段目のゲートは自前で用意しました。
ホスティングの制約が、認証の設計を 1 段増やした形です。🧱
service_role の鍵は、ブラウザに絶対に渡さないのが大前提です。
管理画面でも、DB にアクセスするのはサーバー側のコードだけにしています。
バイパスしても「何でもできる」わけではない
ここが、意外と見落としがちな点です💡
service_role は RLS を無視できますが、テーブルの権限(GRANT)は無視できません。
PostgreSQL では、この 2 つは別の仕組みだからです。⚙️
読み書きできるか = テーブルの権限(GRANT)がある AND RLS のポリシーを通る
↑ service_role はここだけを無視する
あじぴたでは、これを使って管理画面からも触れないテーブルを作っています🔐
従量課金 API の呼び出し回数を記録するテーブルです。
上限の判定を「このテーブルに何行あるか」で行っているので、行を消せる人は、上限をリセットできることになります。⚠️
実際に service_role で試すと、上限まで埋めた 1,000 行を消せてしまい、上限がリセットされることを確認しました😇
select consume_geocode_quota('anyone'); -- day(上限に達している)
delete from geocode_request_log; -- DELETE 1000(消せてしまう)
select consume_geocode_quota('anyone'); -- ok(上限がリセットされた)
そこで、このテーブルの権限は service_role からも外しました。
上限を判定する関数そのものが特別な権限で動くので、呼び出し側にテーブルの権限は要りません✅
RLS をバイパスする相手を縛れるのは、GRANT だけ。
「管理者権限 = 何でもできる」にしないために、本当に触らせたくないテーブルは GRANT で閉じる。
DB の構造の変更は、1 か所だけで管理する
同じ DB を 2 つのリポジトリから触ると、もう 1 つ問題が出ます。
DB の構造の変更(マイグレーション)を、どちらが持つかです🤔
両方が持つと、どちらが先に適用されるかで DB の状態が変わってしまいます。
そこで、マイグレーションはアプリ本体のリポジトリだけが持つと決めました📌
管理画面が使うテーブル(運営スタッフのアカウントなど)も、アプリ本体側のマイグレーションとして管理しています。
| アプリ本体 | 管理画面 | |
|---|---|---|
| マイグレーション | こちらが正 | 持たない |
| テーブル定義 | 持つ | アプリ本体のものを前提に動く |
「管理画面のテーブルなのに、管理画面のリポジトリに無い」という状態は、最初は違和感があります。
でも、DB は 1 つなので、構造の正解も 1 か所にあるべき、と考えると素直です👍
つまずいたポイント:RLS は正しいのに、読めなかった
最後に、アプリ側で実際に踏んだ失敗を 1 つ書きます🙈
「RLS と GRANT は別の仕組み」というのを、身をもって知った件です。
起きたこと
プレビュー環境で、一部のユーザーが特定の画面で 404 になり、そこから抜け出せなくなるという事故が起きました😱
調べると、DB がこう言っていました。
permission denied for table tags
hint: GRANT SELECT ON public.tags TO anon;
RLS のポリシーは正しく設定されていました。「全員が読める」というポリシーです。
**なのに読めない。**😨
原因:GRANT を「自動で付くもの」と思い込んでいた
さきほどの式のとおり、読めるかどうかは GRANT と RLS の両方で決まります。
Supabase は、テーブルを作ると自動で GRANT を付けてくれます。🤖
ローカルや通常の手順で適用した環境では、これが効きます。
ところが、別の手順で適用した環境では、この自動付与が効いておらず、GRANT が一切付いていませんでした😵
書き込みの権限は各マイグレーションで明示的に書いていたので無事でした。✍️
読み取りの権限だけが、自動付与に頼っていて抜け落ちていたんです。
直し方:「自動で付く」を、マイグレーションに明示する
- 基本の GRANT を、マイグレーションとして書く
- これから作るテーブルにも付くよう、既定の権限も宣言する
- このマイグレーションは何度流しても同じ結果になるので、適用すれば壊れた環境も直る
あわせて、アプリ側も直しました。データの取得に失敗したときに 404 を返していたのが、
リダイレクトの繰り返しと組み合わさって抜け出せない原因になっていました。
エラー画面を出して、やり直せるようにしています✅
「Supabase が自動でやってくれる」に頼ると、その自動が効かない環境で静かに壊れる。
権限は、マイグレーションに明示して自己完結させる。
管理画面側の「service_role から GRANT を外す」も、アプリ側のこの失敗も、
根っこは同じです。RLS と GRANT は別の仕組みで、どちらか片方だけを見ていると穴が開きます🕳️
まとめ
- 信頼できない相手(ブラウザ)には RLS、信頼できる相手(運営スタッフ)には入口を絞る🔑
- 「全部見せるための RLS」は書かない。バイパスする代わりに、認証を何重にもして入れる人を絞る🛡️
-
RLS と GRANT は別の仕組み。
service_roleを縛れるのは GRANT だけなので、権限はマイグレーションに明示する🔐 - 同じ DB を複数のリポジトリから触るなら、マイグレーションは 1 か所だけが持つ📌
「管理画面のために RLS をどう書くか」で悩んでいたら、そもそも RLS で守る相手なのかから考えてみてください💡
シリーズの他の記事も、よろしければ📚
このシリーズでは、個人開発サービス「あじぴた」の設計をテーマごとに書いています。
サービスの全体像や、ほかの記事の一覧はハブ記事にまとめています👇
🔗 「低評価を公開しない」口コミサービスを個人開発した話 — 4,300コミット・6リポジトリの全体像
これまでに公開した記事です。
🔗 Google Places APIで月$1,440の請求が来る前に — 個人開発で従量課金を「呼ばない」8層の防壁
🔗 Next.js 16 で Web Vitals を測って直した実録!loading.tsx で LCP が 2 倍になった罠と関数リージョン
次の記事では、Next.js の App Router のディレクトリ設計を、実サービスで答え合わせした話を書きました📁
🔗 ディレクトリ構成は「AIへの指示書」になる。Next.js App Routerで自分の設計論を答え合わせした話
設計の中身を通して読みたい方には、解剖ドキュメントも公開しています🔬
🔗 あじぴたの内側(ajipita-inside)
参考になれば幸いです🙏