記事一覧に戻る
Supabaseをバックエンド経由で使うときの落とし穴 ― そのDB、外から丸見えですよ

Supabaseをバックエンド経由で使うときの落とし穴 ― そのDB、外から丸見えですよ

SupabaseをバックエンドAPI経由で使っていても、RLSを有効化しないとanon keyで全データにアクセスできてしまう。ローカル検証で気づいた落とし穴と対策について。

Supabase、便利ですよね。認証・DB・ストレージがまとめて使えて、個人開発からスタートアップまで幅広く採用されている印象です。

Papercalでも Supabase を使っているのですが、BaaS としてフロントエンドから直接叩くのではなく、バックエンドAPI経由でDBにアクセスする構成を採用しています。ローカルで検証しているときに、この構成特有の落とし穴に気づきました。

RLSが無効だと、anon key(Publishable key)でData APIから全データにアクセスできてしまう。

バックエンドでservice_role keyを使ってるから大丈夫、と思い込んでいたのですが、フロントエンドにanon keyが露出している時点でリスクがあったという話です。

前提:Papercalの構成

[フロントエンド (React)]
    ↓ Supabase Auth(anon key)
[Supabase] ← 認証のみ

[フロントエンド]
    ↓ REST API
[バックエンド (Hono)]
    ↓ service_role key
[Supabase DB] ← DBアクセスはここだけ

フロントエンドではSupabaseのAuth機能だけを使っていて、DBアクセスは一切行っていません。バックエンドがservice_role keyでDBにアクセスする構成です。

この構成なら「フロントエンドからDBにアクセスする経路がない」と思っていました。

問題:Data APIという経路が存在する

Supabaseには「Data API」という機能があって、https://<project>.supabase.co/rest/v1/<table> にリクエストを投げると、直接テーブルのデータを取得できます。

# anon keyがあれば誰でも叩ける
curl 'https://xxx.supabase.co/rest/v1/users?select=*' \
  -H "apikey: <anon_key>" \
  -H "Authorization: Bearer <anon_key>"

2つのヘッダーが必要な理由は、それぞれ役割が異なるからです:

  • apikey: APIゲートウェイがプロジェクトへのアクセスを検証するために使用
  • Authorization: Bearer: PostgRESTがJWTを解析してRLSのコンテキスト(auth.uid()など)を決定するために使用

匿名アクセスの場合、どちらにもanon keyを渡します。認証済みユーザーの場合はapikeyにanon key、AuthorizationにはユーザーのJWTを渡す形になります(参考:Understanding API keysSecuring your API)。

anon keyはフロントエンドのコードに埋め込まれているので、ブラウザのDevToolsで簡単に見つかります。つまり、RLSが無効な状態だと誰でもData API経由で全テーブルのデータを取得できてしまう

バックエンドAPI経由でしかDBアクセスしない設計にしていても、この経路は塞がっていなかったわけです。

Data APIの仕組み:PostgREST

調べてみると、SupabaseのData APIは内部的にPostgREST(PostgreSQLをRESTful APIとして公開するミドルウェア)を使っているようです。

PostgRESTはデフォルトでpublicスキーマのテーブルを公開します。つまり、publicスキーマにテーブルを作ると、自動的にData APIからアクセス可能になる。Drizzleで普通にテーブルを定義するとpublicスキーマに作られるので、意識しないとこの状態になります。

RLSを有効化すると、PostgREST経由のリクエストに対してもポリシーが適用されるので、許可されていないアクセスはブロックされます。

対策:全テーブルでRLSを有効化する

Row Level Security(RLS)を有効化すると、ポリシーで明示的に許可されていないアクセスは拒否されます。

ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "events" ENABLE ROW LEVEL SECURITY;
-- 全テーブルに対して実行

RLSを有効化して、かつポリシーを何も設定しなければ、anon key経由のアクセスは全て拒否されます。

Drizzleでの設定

Drizzle ORMを使っている場合は、スキーマ定義で.enableRLS()を付けるだけです。

import { pgTable, uuid, varchar } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: uuid("id").primaryKey(),
  email: varchar("email", { length: 255 }).notNull(),
  // ...
}).enableRLS();  // これを追加

マイグレーションを生成すると、ALTER TABLE ... ENABLE ROW LEVEL SECURITY が含まれます。

ちなみに、Drizzleはマイグレーション履歴を管理するためにdrizzleというスキーマを作成しますが、これはpublicスキーマではないのでData APIからはアクセスできません。RLSの設定も不要です。

service_role keyはRLSをバイパスする

バックエンドでservice_role keyを使っている場合、RLSは適用されません。service_role keyは「管理者権限」扱いで、ポリシーに関係なく全データにアクセスできます。

つまり、この構成が成り立ちます:

  • anon key(フロントエンド): RLSでブロック → Data APIからのアクセス不可
  • service_role key(バックエンド): RLSバイパス → 通常通りDBアクセス可能

バックエンドの動作に影響を与えずに、Data API経由の不正アクセスだけを防げる。

新規テーブル作成時の注意

RLSはテーブル単位の設定なので、新しいテーブルを追加するときは忘れずに有効化する必要があります。

Drizzleの場合、.enableRLS()を付け忘れると、そのテーブルだけ穴が開いた状態になります。コードレビューで見落としやすいポイントなので、チェックリストに入れておくといいかもしれません。

// 新規テーブル作成時
export const newTable = pgTable("new_table", {
  // ...fields
}).enableRLS();  // ← 忘れずに

Data APIを完全に無効化する選択肢もある

RLSではなく、Data API自体を無効化する方法もあります。Supabaseのダッシュボードから設定できます。

ただ、将来的にフロントエンドから直接DBアクセスしたくなる可能性もあるので、RLSで制御しておく方が柔軟性は高いと思います。

補足:RLSがあればバックエンドなしでもDBアクセスできる

ここまでRLSを「Data APIからの不正アクセスを防ぐ」という文脈で説明してきましたが、RLSにはもう一つの側面があります。

RLSを適切に設定すれば、フロントエンドからData APIを直接叩いても安全にDBアクセスできるようになります。つまり、バックエンドを介さずにDBアクセスが実現できる。

-- 例:ユーザーは自分のデータだけ読み取れる
CREATE POLICY "Users can read own data"
ON users FOR SELECT
USING (auth.uid() = id);

このようなポリシーを設定すると、Authorization: Bearerに渡されたJWTからauth.uid()が抽出され、ログインユーザーは自分のレコードだけにアクセスできるようになります。

Supabaseがこのアーキテクチャを採用しているのは、RLSによってレコード単位でユーザーごとのアクセス権限を制御できるからです。PostgreSQLのRLS機能を使って、DB層でマルチテナンシーを実現しているわけです。

Papercalでは「バックエンドでビジネスロジックを書きたい」という理由でバックエンド構成を採用していますが、シンプルなCRUD操作だけならバックエンドなしの構成も十分にありえます。RLSは「防御」だけでなく「バックエンドレス開発の実現手段」でもあるということですね。


バックエンド経由でしかDBを触らないから安全、という思い込みは危険でした。Supabaseを使うなら、RLSは「とりあえず全テーブルで有効化しておく」くらいの気持ちでいた方がよさそうです。