naisei.dev › 安全点検 › Supabase 5分点検
無料・読むだけ
AIで作ったアプリ、
外から読めていないか5分で自分で確かめる
2026年9月、AIで作ったアプリによく使われる Supabase で、外から中身を読めるデータベースが16,326件見つかったと報じられました。 下のSQLを1本貼るだけで、自分のアプリがそうなっていないかを確かめられます。
- 表の中身は読みません
- 何も書き換えません
- 結果はどこにも送られません
9月に何が起きたか
-
16,326
件米国のセキュリティ会社 UpGuard が、Supabase を使っているサイト約30万を調べ、外から読める表を持つデータベースを16,326件見つけました。
-
半数
以上そのうち半数以上に、個人情報らしきものがありました。パスワードや認証の鍵、カード情報らしきものも一部にあったそうです。
-
原因
多くは表の鍵(RLS)のかけ忘れと、画面に入れる「公開用の鍵」を秘密の鍵のつもりで使っていたこと。AIの開発ツールが作る表には、鍵が自動ではかからないことがあります。
-
AIと
報告では、Supabase を「Claude Code が最もよく勧めるデータベース」としています。作った本人が、鍵の設定を知らないままのことが多い、とも。
出典:TechCrunch(2026年9月25日)/UpGuard の報告(いずれも英語)
なぜ、外から読めてしまうのか
アプリの画面には「公開キー」が入っています。これは画面を開けば誰でも見られる鍵です。データを守っているのは公開キーではなく、表ごとの鍵(RLS)と決まり(ポリシー)です。
5分の手順
-
Supabase を開く
ログインして、点検したいプロジェクトを開きます。本番のプロジェクトで大丈夫です(設定を読むだけです)。
-
「SQL Editor」で新しい画面を出す
左の列の「SQL Editor」を押し、「New query」で空の画面を出します。
-
下のSQLを貼って「Run」
「コピー」を押して貼り付け、右下の「Run」を押します。数秒で結果の表が出ます。
-
結果を上から見る
「要対応」→「確認」→「OK」の順に並びます。読み方はこのすぐ下にあります。
-- naisei.dev 無料の自己点検「Supabase 5分点検」(2026年10月版)
-- ・見るのは設定だけです。表の中身(会員の情報など)は読みません
-- ・何も書き換えません(SELECT 1文だけです)
-- ・結果はこの画面にだけ出ます。どこにも送られません
-- 使い方:Supabase の SQL Editor に全部貼り付けて「Run」
-- API で公開するスキーマを増やしている場合は、すぐ下の ('public') に足してください。例:('public'), ('api')
with exposed(nsp) as (values ('public')),
rels as (
select c.oid, n.nspname || '.' || c.relname as name, c.relkind, c.relrowsecurity,
coalesce(array_to_string(c.reloptions, ','), '') as opts
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname in (select nsp from exposed)
and c.relkind in ('r', 'p', 'v', 'm')
and not exists (select 1 from pg_depend d
where d.classid = 'pg_class'::regclass and d.objid = c.oid and d.deptype = 'e')
),
pii as (
select a.attrelid as oid, string_agg(a.attname, ', ' order by a.attnum) as cols
from pg_attribute a
where a.attrelid in (select oid from rels) and a.attnum > 0 and not a.attisdropped
and a.attname ~* '(mail|phone|tel|address|addr|zip|postal|birth|password|passwd|token|secret|card|full_?name|first_?name|last_?name|kana|line_?user|氏名|名前|住所|電話|生年月日)'
group by a.attrelid
),
pol as (
select r.oid, r.name, p.policyname, p.cmd,
p.roles && array['anon', 'public']::name[] as to_anon,
p.roles && array['authenticated']::name[] as to_auth,
coalesce(p.qual, '') as qual, coalesce(p.with_check, '') as wcheck
from pg_policies p
join rels r on r.name = p.schemaname || '.' || p.tablename
where p.permissive = 'PERMISSIVE'
),
f as (
-- 1) 鍵(RLS)がかかっていない表
select case when has_table_privilege('anon', r.oid, 'select') then 1
when has_table_privilege('authenticated', r.oid, 'select') and pii.cols is not null then 1
when has_table_privilege('authenticated', r.oid, 'select') then 2
else 3 end as lv,
r.name::text as target,
(case when has_table_privilege('anon', r.oid, 'select')
then '鍵(RLS)がかかっておらず、ログインなしで誰でも全部読めます'
|| case when has_table_privilege('anon', r.oid, 'insert,update,delete')
then '。書き換え・削除もできます' else '' end
when has_table_privilege('authenticated', r.oid, 'select')
then '鍵(RLS)がかかっておらず、ログインすれば誰でも全部読めます(新規登録を誰でもできるなら、実質だれでも)'
else '鍵(RLS)はかかっていませんが、API からは読めない設定です' end
|| coalesce('。個人情報らしき列:' || pii.cols, ''))::text as what,
(case when has_table_privilege('anon', r.oid, 'select') or has_table_privilege('authenticated', r.oid, 'select')
then 'この表の RLS を有効にし、読んでよい人だけに開くポリシーを作る'
else '—' end)::text as todo
from rels r left join pii on pii.oid = r.oid
where r.relkind in ('r', 'p') and not r.relrowsecurity
union all
-- 2) 鍵はあるが「誰でも全部読める」決まり
select case when pii.cols is not null then 1 else 2 end,
p.name::text,
('「' || p.policyname || '」の決まりで、'
|| case when p.to_anon then 'ログインなしで誰でも全部読めます'
else 'ログインすれば誰でも全部読めます(新規登録を誰でもできるなら、実質だれでも)' end
|| coalesce('。個人情報らしき列:' || pii.cols, '。お知らせや商品など、公開してよい表なら問題ありません'))::text,
('読んでよい人の条件(本人の分だけ、など)に書き直す')::text
from pol p left join pii on pii.oid = p.oid
where p.cmd in ('SELECT', 'ALL') and p.qual = 'true'
and ((p.to_anon and has_table_privilege('anon', p.oid, 'select'))
or (p.to_auth and has_table_privilege('authenticated', p.oid, 'select')))
union all
-- 3) 誰でも書き換え・削除できる決まり
select 1, p.name::text,
('「' || p.policyname || '」の決まりで、'
|| case when p.to_anon then 'ログインなしで' else 'ログインすれば' end
|| '誰でも全部の行を' || case p.cmd when 'DELETE' then '削除' when 'UPDATE' then '書き換え' else '書き換え・削除' end
|| 'できます')::text,
('書き換えてよい人の条件(本人の行だけ、など)に書き直す')::text
from pol p
where p.cmd in ('UPDATE', 'DELETE', 'ALL') and p.qual = 'true'
and ((p.to_anon and has_table_privilege('anon', p.oid, 'update,delete'))
or (p.to_auth and has_table_privilege('authenticated', p.oid, 'update,delete')))
union all
-- 4) 誰でも書き込める決まり(申込フォームなら想定どおり)
select 2, p.name::text,
('「' || p.policyname || '」の決まりで、ログインなしで誰でも行を足せます。申込フォームなど、想定どおりなら問題ありません')::text,
('いたずら送信の対策(回数の制限など)があるか確かめる')::text
from pol p
where p.cmd = 'INSERT' and p.wcheck = 'true' and p.to_anon
and has_table_privilege('anon', p.oid, 'insert')
union all
-- 5) 表の鍵を素通りするビュー
select case when pii.cols is not null or pg_get_viewdef(r.oid) ~* 'auth\.users' then 1 else 2 end,
r.name::text,
('このビューは表の鍵(RLS)を素通りします。'
|| case when has_table_privilege('anon', r.oid, 'select') then 'ログインなしで' else 'ログインすれば' end
|| '誰でも読めます'
|| case when pg_get_viewdef(r.oid) ~* 'auth\.users' then '。ログイン用の名簿(auth.users)を見ています' else '' end
|| coalesce('。個人情報らしき列:' || pii.cols, ''))::text,
('ビューに security_invoker = true を付けるか、読める人を絞る')::text
from rels r left join pii on pii.oid = r.oid
where r.relkind = 'v' and r.opts !~* 'security_invoker=(true|on|yes|1)'
and (has_table_privilege('anon', r.oid, 'select') or has_table_privilege('authenticated', r.oid, 'select'))
union all
-- 6) 鍵をかけられない集計の表(マテリアライズドビュー)
select case when pii.cols is not null then 1 else 2 end,
r.name::text,
('集計の表(マテリアライズドビュー)には鍵をかけられません。'
|| case when has_table_privilege('anon', r.oid, 'select') then 'ログインなしで' else 'ログインすれば' end
|| '誰でも読めます' || coalesce('。個人情報らしき列:' || pii.cols, ''))::text,
('API から読めないように権限を外し、必要なら関数ごしに出す')::text
from rels r left join pii on pii.oid = r.oid
where r.relkind = 'm'
and (has_table_privilege('anon', r.oid, 'select') or has_table_privilege('authenticated', r.oid, 'select'))
union all
-- 7) 作った人の権限で動く関数(表の鍵を素通りできる)
select 2, (n.nspname || '.' || p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')')::text,
(case when has_function_privilege('anon', p.oid, 'execute') then 'ログインなしで' else 'ログインすれば' end
|| '呼べる関数で、作った人の権限で動きます(表の鍵を素通りできます)')::text,
('中で「呼んだ本人の分だけ」を返しているか確かめる。要らなければ実行の権限を外す')::text
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname in (select nsp from exposed) and p.prosecdef and p.prokind = 'f'
and p.prorettype <> 'trigger'::regtype
and not exists (select 1 from pg_depend d
where d.classid = 'pg_proc'::regclass and d.objid = p.oid and d.deptype = 'e')
and (has_function_privilege('anon', p.oid, 'execute') or has_function_privilege('authenticated', p.oid, 'execute'))
union all
-- 8) 公開のバケット(ファイル置き場)
select 2, ('ファイル置き場 ' || b.id)::text,
('公開の設定です。URL が分かれば、ログインなしで誰でも見られます')::text,
('名簿・申込書・写真などを置いていないか確かめる。置くなら非公開に')::text
from storage.buckets b
where b.public
union all
-- 9) ファイルの取り出しを誰にでも許す決まり
select 2, ('ファイル置き場の決まり「' || p.policyname || '」')::text,
('ログインなしでファイルの一覧・取り出しを許しています(条件:' || coalesce(p.qual, 'なし') || ')')::text,
('非公開のファイルまで取り出せないか、条件を確かめる')::text
from pg_policies p
where p.schemaname = 'storage' and p.tablename = 'objects' and p.permissive = 'PERMISSIVE'
and p.cmd in ('SELECT', 'ALL') and p.roles && array['anon', 'public']::name[]
),
ok as (
select 3 as lv, '鍵(RLS)で守られている表'::text as target,
(count(*) || '個:' || array_to_string((array_agg(r.name order by r.name))[1:12], '、')
|| case when count(*) > 12 then ' ほか' else '' end)::text as what,
'—'::text as todo
from rels r
where r.relkind in ('r', 'p') and r.relrowsecurity and r.name not in (select target from f)
having count(*) > 0
union all
select 3, 'ファイル置き場(非公開)'::text, (count(*) || '個')::text, '—'::text
from storage.buckets b where not b.public
having count(*) > 0
union all
select 3, '全体'::text, '要対応は見つかりませんでした'::text, '「確認」の行が意図どおりかだけ見てください'::text
where not exists (select 1 from f where lv = 1)
)
select case lv when 1 then '要対応' when 2 then '確認' else 'OK' end as "判定",
target as "対象", what as "何が起きるか", todo as "次にやること"
from (select * from f union all select * from ok) x
order by lv, target;
API で公開するスキーマを増やしている場合は、SQLの7行目の ('public') に足してください(例:('public'), ('api'))。
結果の読み方
結果は、次のような表で出ます(見本です)。
| 判定 | 対象 | 何が起きるか |
|---|---|---|
| 要対応 | public.members | 鍵(RLS)がかかっておらず、ログインなしで誰でも全部読めます。書き換え・削除もできます。個人情報らしき列:name, phone, email |
| 確認 | public.news | 「誰でも読める」の決まりで、ログインなしで誰でも全部読めます。お知らせや商品など、公開してよい表なら問題ありません |
| OK | 鍵(RLS)で守られている表 | 8個:public.lessons、public.payments、… |
-
要対応
外の人が、今すぐ読める・書き換えられる
公開キーさえあれば誰でもできる状態です。今日のうちに直しましょう。読まれたかどうかは、このSQLでは分かりません。
-
確認
意図どおりなら問題なし
お知らせ・商品・申込フォームなど、もともと誰でも使ってよいものなら大丈夫です。意図していなければ「要対応」と同じに扱ってください。
-
OK
鍵で守られている
表の鍵(RLS)がかかっていて、広く開いた決まりもありません。
直すときは
直すと、今まで鍵なしで動いていた画面が止まることがあります。先にバックアップ(Supabase の Database → Backups)があるかを確かめてから進めてください。
AIの開発ツール(Claude Code・Cursor など)で直すなら、結果の表を貼って、次のように頼むと安全です。
Supabase の設定を点検したところ、下の結果が出ました。「要対応」の行を直したいです。 直す前に、次の2つを一覧にして見せてください。私が確かめてから実行してください。 1. どの表に、どんな鍵(RLS)と決まり(ポリシー)を作るか 2. それで動かなくなる画面や機能はどれか 本番のデータは消さないでください。 (ここに結果の表を貼る)
このSQLで分からないこと
このSQLが見るのは、データベースの設定だけです。次のものは見ていません。
- 秘密の鍵が画面に入っていないか。自分のコードを「service_role」「sb_secret_」で検索してみてください。画面側のコードに出てきたら、すぐに鍵を作り直す必要があります。
- 誰でも新規登録できる設定か。「ログインすれば誰でも読めます」の行は、誰でも登録できるなら、実質だれでも読めます(Authentication の設定)。
- サーバー側の処理。メールやLINEを送る口、決済の金額の決め方、Edge Functions の中身など。
- Supabase 以外。Firebase などは、このSQLでは調べられません。
ここから先は、安全点検(有料)で見ます。コードと設定を拝見して、上の4つを含む70あまりの項目を点検し、直し方まで報告します。本番は攻撃しません。料金は8万円(先着3社はモニター料金4万円)です。
結果を見ても分からなければ、LINEで一言。
「要対応が3件、どこから直す?」だけで構いません。結果の表は送らなくて大丈夫です。
よく聞かれること
- 本番のデータベースで流して大丈夫ですか?
- はい。設定を読むだけの SELECT 1文で、何も書き換えません。表の中身(会員の情報など)も読みません。
- 結果をどこかに送る必要はありますか?
- ありません。結果はお使いの Supabase の画面に出るだけで、naisei.dev には何も届きません。
- 「要対応」が出ました。もう読まれていますか?
- このSQLでは分かりません。分かるのは「今、読める状態か」までです。まず直して、心配なら Supabase のログ(Logs)で、心当たりのないアクセスがないか見てください。
- 「個人情報らしき列」はどうやって決めていますか?
- 列の名前(email・phone・address など)から推測しています。名前が違えば拾えないので、目安として見てください。
- Supabase 以外でも使えますか?
- このSQLは Supabase 専用です。ほかの基盤は安全点検でご相談ください。
直すところまで頼みたいときは
30分のオンライン相談で、何から直すかを一緒に決めます。
相談フォームは、「安全点検」を選んだ状態で開きます。
無料で、営業目的の折り返しは一切ありません。