はじめに ― 何が話題になったのか
2026年4月、大学生向けのマッチングアプリで、他のユーザーのメールアドレス・大学名・顔写真のURL・本人確認が済んでいるかどうかのフラグなどを含むJSONが、第三者から取得できる状態だったとSNSで指摘されました。指摘された問題点は次の3つです。
- ログインしていなくても、全ユーザーの情報が取れた
- メールアドレスのドメインの判定を、ブラウザ側でしか行っていなかった
- 本人確認済みのフラグを、ユーザー自身が書き換えられた
これは高度なハッキング技術の話ではありません。データベースの権限の設定ミスによって、ブラウザから普通に見えてしまうという話です。Supabase を使い、ブラウザから直接データを取りに行く構成なら、どのプロジェクトでも起こり得ます。
筆者は Next.js と Supabase でアプリを作っている制作者です。この記事は、自分のプロジェクトを確認するときに見ている観点をまとめたものです。内容は2026年9月時点の情報で、Supabase の公式ドキュメントに基づいています。
そもそも「内部のJSON」はどこから来るのか
画面に出していない項目でも、ブラウザが受け取ったJSONに入っていれば、誰でも見られます。
SPA や Next.js のブラウザ側のコード(Client Component など)は、API や Supabase の Data API から JSON を受け取って画面を描いています。Data API は PostgREST という仕組みで、テーブルごとに REST API を自動で用意してくれるものです。supabase.from('profiles').select() と書くと、裏ではこの API に HTTPS のリクエストが飛んでいます。
受け取ったレスポンスは、開発者ツールの Network タブで中身をそのまま確認できます。画面にメールアドレスを表示していなくても、レスポンスに email が入っていれば見えてしまいます。「画面に表示していない」は「隠れている」という意味ではありません。
さらに、同じリクエストはブラウザ以外からでも送れます。Data API にとっては、ブラウザからのリクエストも curl やスクリプトからのリクエストも区別がつきません。
Supabaseの前提 ― anonキーは「公開される鍵」
anon キー(新しい形式では publishable キー)は、ブラウザ側の JavaScript に埋め込んで使う鍵です。ページを開いた人なら誰でも読めるので、秘密にはできません。もともと秘密にする前提の鍵でもありません。
そのため、守りはキーを隠すことではなく、データベース側で行います。Supabase の公式ドキュメントでは、Data API は2段階の仕組みで守るものと説明されています。
- Grant(権限):どのロールが、どのテーブルやビュー・関数に触れるか
- RLS(Row Level Security):そのロールが、どの行を読めるか・書き換えられるか
ここでいう「ロール」は Postgres のロールのことで、ログインしていないリクエストは anon、ログインしているユーザーのリクエストは authenticated として扱われます。公式は「公開するすべてのオブジェクトで、両方を使う」ことを求めています。
もうひとつの service_role キー(新しい形式では secret キー)は、RLS を無視してすべての行を読み書きできる鍵です。サーバー側の処理だけで使い、ブラウザに渡るコード(Next.js なら NEXT_PUBLIC_ が付いた環境変数など)には絶対に入れないでください。
パターン1 ― RLSが有効になっていないテーブル
もっとも影響が大きいのがこのパターンです。RLS が無効で、anon に SELECT の権限があるテーブルは、公開URLと anon キーだけで全行が取れます。
「Supabase は RLS が最初からオフ」と言われることがありますが、正確ではありません。テーブルをどこで作ったかによって違います。
- ダッシュボード(Table Editor)で作ったテーブル:RLS が最初から有効
- SQL Editor、マイグレーション、ほかのツール(ORM や AIコーディングツールなど)で作ったテーブル:自分で有効にする必要がある
さらに既存のプロジェクトでは、public スキーマに作ったテーブルに、anon・authenticated・service_role への SELECT/INSERT/UPDATE/DELETE の権限が自動で付きます。つまり SQL でテーブルを作って RLS を有効にし忘れると、「誰でも読めて、誰でも書き換えられる」状態になります。
対策は、公開しているすべてのテーブルで RLS を有効にすることです。
alter table public.profiles enable row level security;
RLS を有効にしてポリシーを1つも作らなければ、Data API からはどの行も読めなくなります(ブラウザから見ると空の配列が返ります)。必要な操作ごとにポリシーを足していく、という順番で考えると安全です。マイグレーションでテーブルを作るときは、create table と同じファイルに RLS の有効化とポリシーを書いておくと、有効化のし忘れを防げます。
パターン2 ― RLSはあるがポリシーが緩すぎる
RLS を有効にしただけでは安心できません。ポリシーの中身が緩ければ、RLS が無効なのとほとんど同じです。たとえば次のポリシーは「全員が全行を読める」という意味になります。
-- 危ない例:ログインしていない人も含め、全員が全行・全列を読める
create policy "誰でも閲覧可"
on public.profiles for select
using (true);
to を書かないポリシーは、anon を含むすべてのロールに適用されます。using (true) は「どの行でも条件を満たす」という意味なので、メールアドレスや本人確認の状態が入ったテーブルにこれを付けると、ログインしていない人にまで全ユーザーの情報を返すことになります。
「マッチング相手の一覧を見せたい」という要件でも、返す行と列は絞れます。おすすめは、他の人に見せてよい情報と、本人しか見てはいけない情報を、別のテーブルに分けることです。以下のテーブル名・列名は例です。
-- 他のユーザーに見せてよい列だけを持つテーブル
create table public.public_profiles (
user_id uuid primary key references auth.users(id) on delete cascade,
display_name text,
university text,
photo_path text
);
alter table public.public_profiles enable row level security;
-- 見せてよい列しかないテーブルなので、ログインユーザー全員に読ませてよい
create policy "ログインユーザーは公開プロフィールを閲覧可"
on public.public_profiles for select
to authenticated
using (true);
-- メールアドレスなどの非公開情報は別テーブルにする
create table public.private_profiles (
user_id uuid primary key references auth.users(id) on delete cascade,
email text,
kyc_verified boolean not null default false
);
alter table public.private_profiles enable row level security;
create policy "本人のみ閲覧可"
on public.private_profiles for select
to authenticated
using ((select auth.uid()) = user_id);
ここでも public_profiles には using (true) を使っていますが、上の「危ない例」とは意味が違います。
- テーブルに見せてよい列しか入っていないので、全行を読めても漏れてはいけない情報は含まれない
to authenticatedで、ログインしているユーザーに限定しているので、ログインしていない人には返らない
つまり using (true) そのものが悪いのではなく、「どの列が入ったテーブルに、どのロール向けに付けるか」が問題です。一方、private_profiles は auth.uid()(ログイン中のユーザーのID)と user_id が一致する行、つまり本人の行だけを返します。
大学生限定・同じ大学の人だけに見せる、のようにさらに絞りたい場合は、using の条件に「相手と同じ大学か」などを加えて、返す行を減らします。
パターン3 ― 必要以上の列を返している
RLS は行を制御する仕組みで、列は制御しません。公式ドキュメントでも、RLS は行へのアクセスを細かく制御できる一方、行の中のどの列にアクセスできるかは制御できない、と説明されています。
そのため、本人の行だけを返すポリシーが正しく書けていても、ブラウザ側で select('*') と書いていれば、その行のすべての列が返ってきます。メールアドレス、本人確認の状態、通知用のトークン、管理用のメモなど、画面に出していない列もすべて Network タブで見えます。相手の一覧を返すテーブルに非公開の列が混ざっていれば、他人の情報まで返ることになります。
対策は2つです。
- 見せてよい列だけのテーブル(またはビュー)に分ける(パターン2の
public_profiles)。取得する側がどう書いても、余計な列はそもそも返りません - ブラウザ側では取得する列を明示する。
select('*')は使わず、画面に必要な列だけを書きます
// 画面に必要な列だけを取る
const { data, error } = await supabase
.from('public_profiles')
.select('user_id, display_name, university, photo_path');
ただし、列を明示するのは「うっかり返す」のを防ぐための習慣で、守りにはなりません。リクエストを自分で組み立てれば * で取れてしまうからです。守りの本体は、テーブルを分けることのほうです。
なお、ビューで分ける場合は注意が必要です。Postgres のビューは、既定では作った人(多くの場合 postgres)の権限で動くため、元のテーブルの RLS が効きません。Postgres 15 以降なら with (security_invoker = true) を付けて、呼び出した人の権限で動かすようにします。迷ったら、最初からテーブルを分けておくほうが確実です。
パターン4 ― 自分の行なら何でも書き換えられてしまう
「本人は自分の行を UPDATE できる」というポリシーだけを作ると、その行のすべての列を本人が書き換えられます。kyc_verified(本人確認済み)や role(管理者かどうか)のような、運営が管理するべき列も例外ではありません。画面に編集フォームがなくても、Data API に直接リクエストを送れば書き換えられます。
INSERT のポリシーがある場合も同じです。登録時に kyc_verified = true の行を自分で作れてしまわないかを確認してください。
対策は2つあり、公式ドキュメントが勧めている順に紹介します。
対策1(推奨):運営が管理する列を別テーブルに分ける
パターン2の private_profiles のように、kyc_verified などを本人が編集する列とは別のテーブルに置きます。このテーブルには本人用の UPDATE・INSERT のポリシーを作りません。RLS が有効でポリシーがなければ、ブラウザからは書き込めません。
本人確認が済んだときの書き込みは、サーバー側の処理(service_role キーを使う API や Edge Functions など)からだけ行います。service_role は RLS を無視するので、ポリシーを作らなくても書き込めます。
対策2:列ごとの権限(column privileges)で絞る
同じテーブルのまま、本人が編集してよい列だけに UPDATE の権限を付ける方法です。
-- テーブル全体の UPDATE 権限を外してから、本人が編集してよい列だけ付け直す
revoke update on table public.profiles from authenticated;
grant update (display_name, bio, hobbies) on table public.profiles to authenticated;
create policy "本人の行のみ更新可"
on public.profiles for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
-- UPDATE で対象の行を絞り込むには、その行を読める SELECT のポリシーも必要
create policy "本人の行のみ閲覧可"
on public.profiles for select
to authenticated
using ((select auth.uid()) = user_id);
最後の SELECT のポリシーは忘れやすい部分です。Postgres では、where で行を絞り込む UPDATE は、その行を SELECT のポリシーで読めることも条件になります。UPDATE のポリシーだけだと、エラーにならないまま「0件更新」で終わるので、原因に気づきにくくなります。
もうひとつのポイントは、1行目の revoke です。Postgres では、テーブル全体の権限と列ごとの権限の両方がある場合、テーブル全体の権限が効いてしまいます。前に書いたとおり、既存のプロジェクトでは authenticated にテーブル全体の UPDATE 権限が自動で付いているので、先にそれを外さないと、列ごとの権限を付けても意味がありません。
ただし公式ドキュメントは、列ごとの権限を上級者向けの機能と位置づけ、多くの場合は勧めないとしています。代わりに勧めているのが、RLS のポリシーと専用のテーブルを組み合わせる方法(対策1)です。列が増えるたびに権限を付け直す必要があり、管理が漏れやすいので、特別な理由がなければ対策1を選んでください。
パターン5 ― チェックをブラウザ側だけで行っている
「大学のメールアドレスだけ登録できる」のような制限を、ブラウザ側の JavaScript だけで判定していると、API を直接呼べばすり抜けられます。ブラウザ側のチェックは入力ミスを減らすための親切で、守りにはなりません。
登録時のチェックは、サーバー側で行います。Supabase Auth の場合、方法は主に次の3つです。
- Auth Hook(Before User Created フック):ユーザーが作られる直前に呼ばれ、エラーを返すと登録を拒否できる
- サーバー側の API:登録をサーバー側の処理にまとめ、そこでチェックしてから登録する
- データベースのトリガー:プロフィールのテーブルなど、自分で作ったテーブルへの書き込み時にチェックする
メールアドレスのドメインで絞るなら、Before User Created フックが目的に合っています。公式ドキュメントの例をもとにした、Postgres の関数で書く場合の例です(ドメインの条件は例です)。
create or replace function public.hook_allow_university_email(event jsonb)
returns jsonb
language plpgsql
as $$
declare
email_domain text := lower(split_part(event->'user'->>'email', '@', 2));
begin
-- 例:.ac.jp で終わるドメインだけ登録を許可する
if email_domain like '%.ac.jp' then
return '{}'::jsonb;
end if;
return jsonb_build_object(
'error', jsonb_build_object(
'http_code', 403,
'message', '大学のメールアドレスで登録してください'
)
);
end;
$$;
-- Supabase Auth から呼べるようにし、Data API からは呼べないようにする
grant execute on function public.hook_allow_university_email to supabase_auth_admin;
revoke execute on function public.hook_allow_university_email from authenticated, anon, public;
関数を作ったら、ダッシュボードの Auth Hooks の画面で、この関数を Before User Created フックとして設定します。空のオブジェクトを返すと登録が進み、error を返すと登録が拒否されます。
あわせて、メールアドレスの確認(確認メールのリンクを踏ませる設定)も有効にしておいてください。ドメインの判定は「そのアドレスの持ち主であること」までは確かめないので、確認メールと組み合わせて初めて「その大学のアドレスを持っている人」に絞れます。
リリース前のチェックリスト
公開前に、最低限ここまでは確認しておきたい項目です。
- ダッシュボードの Security Advisor で警告が出ていないか確認する(RLS が無効なテーブルなどを教えてくれます)
- 公開しているすべてのテーブルで、RLS が有効か、ポリシーの中身が正しいかを確認する(
using (true)がどのテーブルに、どのロール向けに付いているか) - 自分のプロジェクトで、ログインしていない状態で主なテーブルを SELECT し、何も返らない(空の配列か権限エラーになる)ことを確認する
- 主な画面を開いて、レスポンスに余計な列が入っていないかを Network タブで確認する
- 本人に書き換えさせてはいけない列(本人確認の状態、権限など)を、ログインユーザーとして UPDATE・INSERT できないか確認する
- service_role キー(secret キー)がブラウザ側のコードに含まれていないか確認する(ビルド後の JS も含めて検索する)
ログインしていない状態の確認は、自分のプロジェクトの URL と anon キーだけを設定したクライアントで行えます。
// 自分のプロジェクトで、ログインしていない状態の動きを確かめる
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
const { data, error } = await supabase.from('private_profiles').select('*');
console.log(data, error); // 期待する結果:空の配列、または権限エラー
新しく作るテーブルに権限が自動で付かないようにする
パターン1の「権限が自動で付く」動きそのものを止めておく方法もあります。公式ドキュメントに載っている SQL です。
alter default privileges for role postgres in schema public
revoke select, insert, update, delete on tables from anon, authenticated, service_role;
alter default privileges for role postgres in schema public
revoke execute on functions from anon, authenticated, service_role;
alter default privileges for role postgres in schema public
revoke usage, select on sequences from anon, authenticated, service_role;
alter default privileges for role postgres in schema public
revoke execute on functions from public;
これを実行したあとに作るテーブルや関数は、権限を付けるまで Data API から触れなくなります。そのため、公開したいテーブルには必要な権限を自分で付ける必要があります(例:grant select on table public.public_profiles to authenticated;)。すでにあるテーブルの権限は変わりません。
公式は「自動で権限を付けるデフォルトをやめ、公開は明示的に選ぶ方式に変えていく」としていますが、時期は書かれていません。プロジェクトによって動きが違う可能性があるので、自分の環境でどうなっているかを確認してください。
まとめ
- anon キーは公開される前提の鍵。守りはキーを隠すことではなく、データベース側で行う
- Grant(どのロールが触れるか)と RLS(どの行に触れるか)の両方で守る
- RLS は列を制御しないので、返す列と、本人に書き換えさせる列も設計する
参考資料
2026年9月時点の Supabase 公式ドキュメントです。