Supabaseを使っていると、Row Level Security、いわゆるRLSを避けて通れない。テーブルを作ってクライアントから読もうとすると、ほぼ例外なく「RLS policyを書こう」という話になる。最初はそれがごく自然に見える。

create policy "Users can read their own rows"
on profiles
for select
using (auth.uid() = user_id);

ところが、少し込み入ったアプリを作りはじめると、だんだん引っかかってくる。これは本当にRLSの仕事なのか、と。

そもそもRLSはPostgreSQLの機能である。それがSupabaseでは、ブラウザやモバイルアプリから直接データを読むための、API認可の中心に鎮座している。

となると、疑問が次々に湧いてくる。RLSはもともとなんのための機能だったのか。SupabaseはRLSをAPIサーバーの代わりにしているのか。業務認可までRLSに寄せてよいのか。アプリケーション層で認可した方が安全なのか。結局、どこまでRLSを使えばよいのか。

先に僕の立場を書いておく。RLSは悪ではない。SupabaseがRLSを使っていることも、「本来想定外の悪用」とまでは言い切れない。ただ、RLSには得意なことと苦手なことがあるのだ。

得意なことと苦手なこと

RLSが得意なのは、データ境界の強制である。tenant_id が違う行は絶対に見せない。user_id が一致する行だけを見せる。published = true の行だけを読ませる。どの経路から来ても越えてはいけない境界をDBで守るのは、RLSの独壇場だ。

一方、承認フローや課金状態、組織階層、例外的な権限はどうか。複数リソースをまたぐ業務判断や、外部サービスの状態に依存する認可もある。これらをRLSだけで抱えると、たちまちつらくなる。こちらは複雑な業務認可で、アプリケーションの領分だからだ。全部をRLSに寄せれば、SQL policyは読みにくく、テストもデバッグもしづらい代物と化す。

そこで僕は、次のように分けている。業務ルール、状態遷移、承認、課金、組織ロールの判断はアプリケーション層に置く。絶対に越えてはいけないデータ境界の強制はRLSに任せる。全部をRLSに寄せるのも、全部をアプリケーション層に寄せるのも危ない。アプリケーション認可を主とし、RLSを最後の戸締まりとして併用するのが、多くの場合いちばん扱いやすいのである。

行単位のアクセス制御

PostgreSQLの通常の権限管理は、基本的にテーブルやカラムの単位で行われる。

grant select on orders to app_user;
revoke update on orders from app_user;
これは「このテーブルを読めるか」「更新できるか」の制御には向いている。ところが、同じ orders テーブルの中で行ごとに持ち主を分けたいとなると話が違う。tenant_id = A の行はTenant Aだけ、tenant_id = B の行はTenant Bだけ、という制御はこの仕組みでは表現しづらい。こうした行単位のアクセス制御をDB側で書くための仕組みが、RLSなのだ。

PostgreSQL WikiのRow-securityのページにも、同じ趣旨の説明がある。従来の権限制御は行単位を区別できず、マルチテナント、ホスティング、高度に機微な環境では問題になる、というくだりだ。つまり、RLSはマルチテナント専用の機能ではない。もっとも、マルチテナント分離が重要なユースケースだったことは間違いなさそうである。

USINGとWITH CHECK

RLSを有効にすると、そのテーブルへの SELECT、INSERT、UPDATE、DELETE はpolicyによって制御される。テナント分離なら、たとえば次のように書ける。
alter table orders enable row level security;

create policy tenant_isolation_select
  on orders
  for select
  to authenticated
  using (
    tenant_id = current_setting('app.tenant_id')::uuid
  );

create policy tenant_isolation_write
  on orders
  for all
  to authenticated
  using (
    tenant_id = current_setting('app.tenant_id')::uuid
  )
  with check (
    tenant_id = current_setting('app.tenant_id')::uuid
  );
USING は、既存の行が見えるかどうかを決める。その行を読んでよいか、更新や削除の対象に選んでよいか、ということだ。対して WITH CHECK は、INSERT や UPDATE のあとの新しい行が許可条件を満たすかを見る。

なお、RLSを有効にしてpolicyがひとつもなければ、基本的にすべて拒否される。これはかなり重要な性質である。RLSは「許可しないものを後から隠す」というより、「明示的に許可したものだけを通す」発想に近い。鍵を掛けた家に、合鍵を一本ずつ配っていくようなものなのだ。

GRANTの上に重なるもの

RLSは通常の権限制御の代わりではない。むしろ GRANT / REVOKE の上に重なる、細粒度の制御である。GRANT / REVOKE がテーブルやカラムに対する大枠の権限を決める。RLSはその権限がある前提で、どの行に触れてよいかを決める。 だから、テーブルに SELECT 権限がなければ、RLS policyがあっても読めない。逆に SELECT 権限があっても、RLS policyが許可しなければ行は見えない。SupabaseのREST Data APIも、grantsとRLSの両方で制御されている。RLSを書いたから権限設計はおしまい、とはならないわけだ。

service role keyという別物

RLSは強力だが、常に全員へ適用されるわけではない。PostgreSQLでは、スーパーユーザーや BYPASSRLS 属性を持つロールはRLSをバイパスできる。テーブル所有者も通常はバイパスするので、所有者にも適用したいなら FORCE ROW LEVEL SECURITY を使う。
alter table orders force row level security;
Supabaseで殊に注意したいのが service_role である。これはRLSをバイパスする特権的なロールとして扱われる。service role keyをフロントエンドに出すなど論外だ。anon keyは公開してよいが、それはRLSと権限設計が前提になっている。なにしろservice role keyはRLSを素通りできる、正真正銘の秘密情報なのである。

裏を返せば、Supabaseの公開可能なキーは「誰にも知られてはいけない秘密」ではない。安全性を決めるのは、RLS、grants、公開schema、functions、viewsの設計だ。ここを勘違いすると、玄関の鍵は掛けたのに窓が全開、という由々しき事態になる。

SupabaseはRLSの役割を変えたのか

ここがいちばんおもしろいところだ。PostgRESTは、PostgreSQLをREST APIとして公開するためのサーバーである。JWTに role claimがあれば、PostgRESTはリクエストごとにそのDB roleへ切り替える。流れはこうだ。クライアントがJWTを持ってリクエストを送り、PostgRESTが SET LOCAL ROLE でロールを切り替え、最後にPostgreSQLがGRANTとRLSで認可する。

Supabaseは、このモデルをプロダクトとして扱いやすくしたものと見てよい。ブラウザやモバイルアプリからSupabase clientでDBにアクセスすると、裏側ではPostgRESTやpg_graphqlなどを通る。そして最終的には、PostgreSQLの権限とRLSで制御される。

押さえておきたいのは、ブラウザがPostgreSQLにTCPで直結しているわけではない点だ。ただし設計上は、「クライアントからDBを直接queryする」体験を提供している。そのため、RLSが公開APIの認可の中心に座ることになる。この意味で、SupabaseはRLSの役割をかなり押し広げたのだ。

かといって、それを直ちに「悪用」と呼ぶのは雑である。PostgreSQLのRLSは、もともと行単位のアクセス制御をDBで強制するための機能だ。PostgREST/Supabaseは、それをHTTP APIの認可に接続したに過ぎない。用途の拡張ではあっても、完全な想定外とまでは言い切れないのである。

経路を問わない境界

RLSが得意なのは、単純で不変なデータ境界の強制だ。自分の user_id の行だけが見える。自分の tenant_id の行だけが見える。公開済みの記事、所属組織のリソース、削除済みでない行だけが読める。どれもデータそのものに近い条件で、アプリケーションのどの経路から来ても変わらない境界である。 管理画面からでも、REST APIからでも、GraphQLからでも、tenant_id の境界だけは越えさせない。バッチからでもSQLクライアントからでも同じだ。こうした制約をDB側で強制できるのは大きい。アプリケーション層だけで認可していると、どこか別の経路で WHERE tenant_id = ? を付け忘れかねない。RLSを入れておけば、付け忘れてもDBが最後に止めてくれる。勝手口の閉め忘れを、家そのものが拒んでくれるわけだ。

業務認可は単純ではない

ところが、業務認可は往々にして単純ではない。所有者は編集できる。組織管理者も編集が許される。担当者は自分の案件だけ更新してよい。

承認済みの注文は編集不可。未払いの組織はエクスポートできない。招待中のユーザーは一部だけ閲覧でき、監査担当者は読み取りのみ。並べているだけで胃が重くなってくる。

こうしたルールは、単なる tenant_id = current_tenant_id では表現できない。ユーザー属性、組織関係、対象リソースの状態、課金状態、承認フロー、時間、外部サービスの状態などが絡んでくる。むろん、SQLで書こうと思えば書ける。exists、helper function、join、view、trigger、security definer functionを組み合わせれば、存外複雑なこともできてしまう。 しかし、できることと保守しやすいことは違う。複雑な業務認可をRLSに寄せすぎると、policyはテーブルごとに散り、認可ルールはSQLオブジェクト全体に蔓延る。テストもデバッグもしづらくなり、実行計画や性能も読みにくくなる。helper functionの権限や search_path まで考える必要が出てくる。viewやfunctionがRLSをバイパスしていないかの確認も欠かせない。

アプリケーション層なら、多くの場合は次のようにドメインの語彙で書ける。

orderPolicy.canEdit({
  actor,
  order,
  organization,
  subscription,
});

同じことをRLSに寄せると、SQLのpredicateとして散らばっていく。

exists (
  select 1
  from organization_members members
  where members.organization_id = orders.organization_id
    and members.user_id = auth.uid()
    and members.role in ('owner', 'admin')
)
and orders.status <> 'approved'

この程度ならまだよい。しかし、これが全テーブル、全操作、例外ルール込みで増殖していったらどうか。全体像を追うのは至難の業である。

他テーブル参照の難しさ

RLS policyからは、他のテーブルを参照することもできる。

create policy organization_member_can_read
on orders
for select
using (
  exists (
    select 1
    from organization_members members
    where members.organization_id = orders.organization_id
      and members.user_id = auth.uid()
  )
);
便利ではあるが、他テーブル参照が入った途端、単純な tenant_id 条件より話がややこしくなる。PostgreSQLのドキュメントでも、他テーブルを参照するpolicyには競合条件の注意書きがある。exists や関数呼び出しが増えるほど、性能面の確認も必要になる。Supabaseのドキュメントが推奨しているのは三つだ。RLS policyで使う列にインデックスを張ること。auth.uid() などの書き方を工夫して評価回数を減らすこと。AdvisorやpgTAPで検証すること。

つまり、RLSは書けたから安全というものではない。書いたpolicyが正しいか、速いか、将来の自分にも読める形か。そこまで見てようやく一人前なのだ。

viewとfunctionという勝手口

RLSを使うなら、テーブルだけを見ていても不十分だ。viewやfunctionも認可境界に影響する。PostgreSQLのviewはデフォルトで所有者の権限の影響を受けるので、view経由でRLSの意図と違うアクセスができないか確かめる必要がある。PostgreSQL 15以降なら、security_invoker = true を指定すれば呼び出し側の権限でviewを評価できる。
create view active_orders
with (security_invoker = true)
as
select *
from orders
where status = 'active';
functionも同様である。SECURITY DEFINER functionは所有者の権限で動くので、便利な反面、権限昇格の入口にもなる。使うなら、search_path を固定し、実行権限を絞っておく。なにをバイパスしてよいのかも、明確にしておかねばならない。

アプリケーション層だけなら安全なのか

RLSが難しいなら、全部アプリケーション層でやればよいのか。そう単純でもない。アプリケーション層だけに認可を置くと、次のような事故が起きる。

// 本来は tenantId で絞るべき
const order = await db.order.findUnique({
  where: {
    id: orderId,
  },
});
orderId が推測可能だったり、別テナントのIDを指定できたりすれば、BOLA / IDORの類いの問題になる。RLSがあれば、アプリケーション層で tenant_id の条件を付け忘れても、DB側が止めてくれる。
using (tenant_id = current_setting('app.tenant_id')::uuid)

だから、RLSを使わない方が常に安全というわけではない。むしろ絶対に越えてはいけない境界ほど、アプリケーション層だけに預けるのは怖いのだ。

Firebase Security Rulesと並べてみる

SupabaseのRLSは、Firebase Security Rulesと並べると理解しやすい。Firestoreでは、WebやモバイルのクライアントがFirestoreに直接アクセスし、Security Rulesがサーバー側で評価される。Supabaseでは、クライアントがPostgRESTやpg_graphqlを通してPostgreSQLにアクセスする。そしてGRANTとRLSがサーバー側で評価される。

FirebaseSupabase
クライアントの接続先FirestorePostgREST / pg_graphql 経由で PostgreSQL
サーバー側のルールSecurity RulesGRANT + RLS
ルールの書き方document / path ベースの独自ルールrelational な table / row / role / policy ベースのSQL

クライアントからの直接アクセスを、サーバー側のルールで守る。どちらもこの思想は同じで、違うのはルールを書く場所とデータモデルだ。FirebaseでSecurity Rulesを甘く書けば危ないのと同じく、SupabaseでRLSやgrantsを甘く書けば危ない。RLSだから危ないのではない。直接アクセスを許すなら、サーバー側ルールの設計がそのままセキュリティ境界になる。ただそれだけの話である。

AIアプリ生成と危険の連鎖

最近のAIアプリ生成ツールは、SupabaseのようなBaaSと組み合わせることが多い。それ自体はなにも悪くない。ただ、非エンジニアや経験の浅い開発者が、公開キー、service role key、RLS、公開schema、プロジェクトのpublic/private設定を混同すると話は別だ。途端に危うくなる。

2026年のLovableの件は、一次情報を見る限り、「SupabaseのRLSが破られた」話ではなさそうだ。プロジェクトの公開範囲やAPI認可、ソースコードやチャット履歴へのアクセス制御の問題として見るべきだと思われる。もっとも、そこから下流でSupabaseの資格情報や秘密情報が漏れる可能性はある。public projectの意味を誤解する。ソースコードや会話履歴が見えるようになる。そこにsecretやservice keyが混入していれば、RLSをバイパスできる経路まで漏れてしまう。AIアプリ生成では、こうした問題が数珠つなぎになりやすい。

これはRLSそのものの欠陥ではない。しかし、RLSやキーの役割を解らないままクライアント直アクセス型のアーキテクチャを使う危険は、確実に大きくなっているのだ。

実務での使い分け

個人的には、次のような設計がいちばん現実的だと考えている。

まず、テナント境界はRLSで守る。マルチテナントSaaSなら、tenant_id の境界をRLSで守る価値は高い。チェック自体はアプリケーション層でも行い、そのうえで最後の戸締まりとしてDBにも持たせる。
using (tenant_id = current_setting('app.tenant_id')::uuid)
with check (tenant_id = current_setting('app.tenant_id')::uuid)

次に、複雑な業務認可はアプリケーション層へ寄せる。承認、課金、状態遷移、込み入った組織ロールは、アプリケーション層のPolicyやDomain Serviceに置く。その方が業務ルールとして読みやすく、テストも書きやすい。

if (!orderPolicy.canApprove({ actor, order, organization })) {
  throw new ForbiddenError();
}
続いて、RLS policyはできるだけ単純に保つ。tenant_id = current_tenant_id、user_id = auth.uid()、published = true くらいの条件ならよい。複数のjoin、大量の exists、複雑な状態遷移、外部状態を含む判定に手を出すと、つらくなりやすい。例外ルールだらけのpolicyは、正直なところ御免蒙りたい。

さらに、RLSもテストする。RLSはSQLなので、アプリケーションの単体テストだけでは検証しきれない。pgTAP、Supabase CLI、DB migration、CIなどを使って、policyそのものを確かめたい。

最低限見ておきたい項目は次のとおり。他テナントの行を読めず、更新もできないこと。INSERT時に別の tenant_id を入れられないこと。anon と authenticated の権限が意図通りであること。service role keyがフロントに出ていないこと。viewやfunctionが意図せずRLSをバイパスしていないこと。 最後に、Supabaseを使うならSecurity AdvisorやDB lintに頼る。RLSが有効なのにpolicyがない。RLSが無効な公開テーブルがある。permissive policyが増えすぎている。security definer のviewやfunctionが意図せず公開されている。public schemaのdefault grantが広すぎる。

こうした状態は早めに見つけたい。RLSは書いて終わりではない。運用中に増えていくテーブル、view、function、grantまで含めて見張り続ける必要があるのだ。

悪用ではなく拡張

では、RLSは悪用されているのか。僕の答えは、悪用とまでは言えないが、役割を広げすぎると危ない、である。

PostgreSQLのRLSは、もともと行単位のアクセス制御をDBで強制するための機能だ。マルチテナント分離は重要なユースケースだったが、それだけの機能ではない。PostgRESTやSupabaseは、このRLSを公開APIの認可の中心へ押し出した。役割はかなり変わったと言ってよい。ただしそれは想定外の悪用ではなく、DBの権限モデルをHTTP APIへ接続した設計なのである。

問題は、RLSになんでもやらせることにある。RLSが得意なのは、単純で不変なデータ境界を守ること。アプリケーション層が得意なのは、複雑な業務判断をドメインの語彙で表すこと。そして、RLSとgrantsが意図通りかは、テストとAdvisorとレビューで検証する。この三つを分けておけば、RLSを信じすぎることも、避けすぎることもない。

以上、RLSの本来の用途とSupabaseでの使われ方、アプリケーション認可との分担について整理した。RLSは家じゅうの判断を引き受ける執事ではなく、最後に掛ける錠である。どこに錠を掛けるかを決めるのは、結局のところ僕たちなのだ。

参考