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:
  絶対に越えてはいけないデータ境界をDB側で強制する

全部RLSに寄せるのも危ない。

全部アプリケーション層だけに寄せるのも危ない。

多くのケースでは、アプリケーション認可を主、RLSを最後の防御線として併用するのが扱いやすい。

RLSは何を解決する機能なのか

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

grant select on orders to app_user;
revoke update on orders from app_user;

これは「このテーブルを読めるか」「このテーブルを更新できるか」を制御するには向いている。

しかし、「同じ orders テーブルの中で、自分のテナントの行だけ読める」という制御は表現しづらい。
orders
  tenant_id = A の行は Tenant A だけ
  tenant_id = B の行は Tenant B だけ

このような行単位のアクセス制御をDB側で表現するための仕組みが RLS だ。

PostgreSQL Wiki の Row-security ページでも、従来の権限制御は行単位を区別できず、マルチテナント、ホスティング、高度に機微な環境では問題になると説明されている。

つまり、RLSは「マルチテナントだけのための機能」ではない。

ただ、マルチテナント分離が重要なユースケースだったことは間違いなさそうだ。

RLSの基本

RLSを有効にすると、そのテーブルへの SELECTINSERTUPDATEDELETE は 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 は、新しく書き込む行が許されるかどうかを決める。

ざっくり言うと、こういう役割だ。

USING:
  その行を読んでよいか
  その行を更新・削除対象として選んでよいか

WITH CHECK:
  INSERT / UPDATE 後の新しい行が許可条件を満たすか

RLSを有効化してpolicyがない場合は、基本的に拒否される。

これはかなり重要だ。

RLSは「許可しないものを後から隠す」というより、「明示的に許可したものだけ通す」発想に近い。

RLSはGRANT/REVOKEの代わりではない

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をバイパスできる。

また、テーブル所有者も通常はRLSをバイパスする。

テーブル所有者にも適用したい場合は、FORCE ROW LEVEL SECURITY を使う。
alter table orders force row level security;
Supabaseで特に注意したいのが service_role だ。 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へ切り替える。

ざっくり言うと、こういう流れになる。

Client
  ↓
JWT
  ↓
PostgREST
  ↓
SET LOCAL ROLE
  ↓
PostgreSQL
  ↓
GRANT + RLS で認可

Supabaseは、このモデルをプロダクトとして扱いやすくしたものだと見られる。

ブラウザやモバイルアプリから Supabase client を使ってDBへアクセスすると、裏側では PostgREST や pg_graphql などを通り、最終的にPostgreSQLの権限とRLSで制御される。

重要なのは、ブラウザが PostgreSQL にTCPで直結しているわけではないこと。

ただし、設計上は「クライアントからDBを直接queryする」体験を提供している。

そのため、RLSは単なるDB内部の補助機能ではなく、公開APIの認可の中心になる。

この意味で、SupabaseはRLSの役割をかなり押し広げたと思う。

ただし、それを直ちに「悪用」と呼ぶのは雑だ。

PostgreSQL RLS自体は、もともと行単位のアクセス制御をDBで強制するための機能だ。

PostgREST/Supabaseは、それをHTTP APIの認可に接続した。

これは用途の拡張ではあるが、完全な想定外とは言い切れない。

RLSが得意なこと

RLSが得意なのは、単純で不変なデータ境界を強制することだ。

たとえば、次のようなルールはRLSと相性がよい。

  • 自分の user_id の行だけ見える
  • 自分の tenant_id の行だけ見える
  • 公開済みの記事だけ読める
  • 所属組織のリソースだけ読める
  • 削除済みではない行だけ見える

これらはデータそのものに近い条件だ。

また、アプリケーションのどの経路からアクセスしても変わらない境界でもある。

管理画面
REST API
GraphQL
バッチ
SQLクライアント

どの経路でも tenant_id 境界は越えさせない

このような制約をDB側で強制できるのは大きい。

アプリケーション層だけで認可していると、別の経路で WHERE tenant_id = ? を付け忘れる可能性がある。

RLSを入れておけば、付け忘れたとしてもDBが最後に止めてくれる。

これがRLSの強いところだ。

RLSだけだとつらいこと

一方で、業務認可はしばしば単純ではない。

たとえば、次のようなルールを考える。

所有者は編集できる
組織管理者も編集できる
担当者は自分の案件だけ更新できる
承認済みの注文は編集できない
未払い組織はエクスポートできない
招待中ユーザーは一部だけ閲覧できる
監査担当者は読み取りだけできる
これらは、単なる 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と比較すると理解しやすい。

Firebase Firestoreでは、WebやモバイルクライアントがFirestoreに直接アクセスし、Security Rulesがサーバー側で評価される。

Supabaseでは、WebやモバイルクライアントがPostgRESTなどを通してPostgreSQLへアクセスし、grantsとRLSがサーバー側で評価される。

Firebase:
  Client
    ↓
  Firestore
    ↓
  Security Rules

Supabase:
  Client
    ↓
  PostgREST / pg_graphql
    ↓
  PostgreSQL
    ↓
  GRANT + RLS

どちらも「クライアント直アクセスをサーバー側ルールで守る」思想を持っている。

違いは、ルールを書く場所とデータモデルだ。

Firebase:
  document / path ベースの独自ルール

Supabase:
  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 の資格情報や秘密情報が漏れる可能性はある。

AIアプリ生成では、次のような問題が連鎖しやすい。

public project の意味を誤解する
  ↓
ソースコードや会話履歴が見える
  ↓
secret / service key が混入している
  ↓
RLSをバイパスできる経路が漏れる

これはRLSそのものの欠陥ではない。

しかし、RLSやキーの役割を理解していない状態で、クライアント直アクセス型のアーキテクチャを使う危険は大きくなったと思う。

実務ではどう使い分けるか

個人的には、次の設計が一番現実的だと思う。

1. テナント境界はRLSで守る

マルチテナントSaaSなら、tenant_id 境界はRLSで守る価値が高い。
using (tenant_id = current_setting('app.tenant_id')::uuid)
with check (tenant_id = current_setting('app.tenant_id')::uuid)

これはアプリケーション層でもチェックする。

ただし、最終防御線としてDBにも持たせる。

2. 複雑な業務認可はアプリケーション層へ寄せる

承認、課金、状態遷移、複雑な組織ロールは、アプリケーション層のPolicyやDomain Serviceに寄せる。

if (!orderPolicy.canApprove({ actor, order, organization })) {
  throw new ForbiddenError();
}

この方が、業務ルールとして読みやすい。

テストも書きやすい。

3. RLSは単純な境界に保つ

RLS policyは、できるだけ単純に保つ。

よい:
  tenant_id = current_tenant_id
  user_id = auth.uid()
  published = true

つらくなりやすい:
  複数join
  大量のexists
  複雑な状態遷移
  外部状態を含む判定
  例外ルールだらけのpolicy

4. RLSもテストする

RLSはSQLなので、アプリケーションの単体テストだけでは検証しきれない。

pgTAP、Supabase CLI、DB migration、CIなどを使って、policyそのものをテストしたい。

最低限、次のような観点は確認したい。

  • 他テナントの行が読めない
  • 他テナントの行を更新できない
  • INSERT時に別tenant_idを入れられない
  • anonauthenticated の権限が意図通り
  • service role key がフロントに出ていない
  • view / function が意図せずRLSをバイパスしない

5. Advisorやlintを使う

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の認可の中心へ押し出した。

これはRLSの役割をかなり変えたと言える。

ただし、それは完全な想定外の悪用ではなく、DBの権限モデルをHTTP APIへ接続した設計だと思う。

問題は、RLSに何でもやらせることだ。

RLSが得意なのは、単純で不変なデータ境界を守ること。

アプリケーション層が得意なのは、複雑な業務判断をドメイン語彙で表現すること。

だから、実務ではこう分けるのがよい。

RLS:
  絶対に越えてはいけないデータ境界を守る

Application:
  複雑な業務認可を判断する

Tests / Advisor / Review:
  RLSとgrantsが意図通りか検証する

RLSを信じすぎても危ない。

RLSを避けすぎても危ない。

大事なのは、RLSを「全部入りの認可エンジン」として扱うのではなく、DBが最後に守るべき境界を明確にすることだと思う。

参考