naisei.dev › 安全点検 › Supabase 5分点検

無料・読むだけ

AIで作ったアプリ、
外から読めていないか5分で自分で確かめる

2026年9月、AIで作ったアプリによく使われる Supabase で、外から中身を読めるデータベースが16,326件見つかったと報じられました。 下のSQLを1本貼るだけで、自分のアプリがそうなっていないかを確かめられます。

  • 表の中身は読みません
  • 何も書き換えません
  • 結果はどこにも送られません

9月に何が起きたか

  1. 16,326
    件

    米国のセキュリティ会社 UpGuard が、Supabase を使っているサイト約30万を調べ、外から読める表を持つデータベースを16,326件見つけました。

  2. 半数
    以上

    そのうち半数以上に、個人情報らしきものがありました。パスワードや認証の鍵、カード情報らしきものも一部にあったそうです。

  3. 原因

    多くは表の鍵(RLS)のかけ忘れと、画面に入れる「公開用の鍵」を秘密の鍵のつもりで使っていたこと。AIの開発ツールが作る表には、鍵が自動ではかからないことがあります。

  4. AIと

    報告では、Supabase を「Claude Code が最もよく勧めるデータベース」としています。作った本人が、鍵の設定を知らないままのことが多い、とも。

出典:TechCrunch(2026年9月25日)/UpGuard の報告(いずれも英語)

なぜ、外から読めてしまうのか

アプリの画面には「公開キー」が入っています。これは画面を開けば誰でも見られる鍵です。データを守っているのは公開キーではなく、表ごとの鍵(RLS)と決まり(ポリシー)です。

鍵なし 画面に入っている公開キー データベース表に鍵がない 返ってくるのは全員の分
鍵あり 画面に入っている公開キー データベース表に鍵(RLS) 返ってくるのは本人の分だけ
穴があっても、アプリはふつうに動きます。だから作った本人は気づけません。

5分の手順

  1. Supabase を開く

    ログインして、点検したいプロジェクトを開きます。本番のプロジェクトで大丈夫です(設定を読むだけです)。

  2. 「SQL Editor」で新しい画面を出す

    左の列の「SQL Editor」を押し、「New query」で空の画面を出します。

  3. 下のSQLを貼って「Run」

    「コピー」を押して貼り付け、右下の「Run」を押します。数秒で結果の表が出ます。

  4. 結果を上から見る

    「要対応」→「確認」→「OK」の順に並びます。読み方はこのすぐ下にあります。

点検SQL(読むだけ・SELECT 1文)
-- 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 など)で直すなら、結果の表を貼って、次のように頼むと安全です。

AIへの頼み方(コピーして使えます)
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件、どこから直す?」だけで構いません。結果の表は送らなくて大丈夫です。

LINEで聞いてみる

よく聞かれること

本番のデータベースで流して大丈夫ですか?
はい。設定を読むだけの SELECT 1文で、何も書き換えません。表の中身(会員の情報など)も読みません。
結果をどこかに送る必要はありますか?
ありません。結果はお使いの Supabase の画面に出るだけで、naisei.dev には何も届きません。
「要対応」が出ました。もう読まれていますか?
このSQLでは分かりません。分かるのは「今、読める状態か」までです。まず直して、心配なら Supabase のログ(Logs)で、心当たりのないアクセスがないか見てください。
「個人情報らしき列」はどうやって決めていますか?
列の名前(email・phone・address など)から推測しています。名前が違えば拾えないので、目安として見てください。
Supabase 以外でも使えますか?
このSQLは Supabase 専用です。ほかの基盤は安全点検でご相談ください。

直すところまで頼みたいときは

30分のオンライン相談で、何から直すかを一緒に決めます。
相談フォームは、「安全点検」を選んだ状態で開きます。
無料で、営業目的の折り返しは一切ありません。

30分無料相談を申し込む