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 keys、Securing 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は「とりあえず全テーブルで有効化しておく」くらいの気持ちでいた方がよさそうです。