メインコンテンツへスキップ
友田 陽大
アプリ層セキュリティ
Supabase
RLS
Next.js
セキュリティ
TypeScript

anonキーとservice_roleキーの正しい扱い — 公開してよい鍵、晒すと即死の鍵、そしてRLSバイパスの境界

Supabaseのanonキーは公開前提だがRLSが効いていることが大前提。service_roleキーはBYPASSRLS権限でRLSを完全に無視し、漏洩すれば全データが露出します。鍵の置き場所・サーバー境界・所有権チェックの設計を、Next.js App Routerの実コードで解説します。

公開日
読了時間
24分
著者
友田 陽大
シェア
目次

最初に結論を述べます。Supabaseの anon キーは「公開してよい鍵」です——ただしそれは、晒されたテーブルすべてでRLS(行レベルセキュリティ)が効いていることが大前提です。一方 service_role キーは「絶対にクライアントへ出してはいけない鍵」で、漏れた瞬間に全データが露出します。 なぜなら service_role はPostgreSQLの BYPASSRLS 権限で動き、あなたが丁寧に書いたRLSポリシーを丸ごと飛び越えるからです。

つまり、どちらの鍵を、どこで使うか——その選択がそのまま攻撃面(attack surface)になります。 鍵は単なる認証情報ではなく、「RLSという防御が効くか/効かないか」を切り替えるスイッチです。anon を選べば最後の砦としてDBのRLSが守ってくれますが、service_role を選んだ瞬間、認可(誰が何にアクセスしてよいか)はすべて自分のコードの責任になります。

本記事は、この2つの鍵の正体と、service_role が「即死」になる仕組み、そして「鍵の置き場所」と「所有権チェック」をどこに置くべきかの設計を、Next.js App Routerの実コードで解説します。鍵の話は単体では完結しません。Next.js × Supabase全体の防御設計は総合ガイドにまとめていますが、その中でも「鍵の扱い」は最も事故が多く、最も取り返しがつかない一点です。


1. 結論:鍵の選択が、そのまま攻撃面になる

要点を先に図解します。Supabaseのクライアントは「どの鍵で作るか」で性質が180度変わります。

anon キー(publishable)service_role キー(secret)
配ってよいか配ってよい(公開鍵)絶対にダメ(秘密鍵)
置き場所ブラウザ・サーバーどちらも可サーバー専用
RLSの扱いRLSが効く(ユーザーの権限で動く)RLSを完全に無視する(BYPASSRLS)
認可の責任DBのRLSが強制してくれる100%自分のコードの責任
漏洩時の被害RLSが正しければ限定的全テーブル露出(即死)

この表の最後の行が、本記事のすべてです。anon キーは漏れても——というより最初からブラウザに配られているので「漏れる」概念がなく——RLSが正しく張られていれば被害は限定的です。service_role キーは漏れた瞬間、攻撃者があなたのデータベースの管理者になります。RLSがどれだけ完璧でも関係ありません。

だから守りの境界は、「RLSがあるか/ないか」ではなく、**「秘密鍵をどこに置き、所有権をどこで強制するか」**に移ります。以降、その境界を一段ずつ分解します。


2. 2つの鍵の正体——anon(公開鍵)と service_role(BYPASSRLSで動くサーバー専用鍵)

anon キー:ブラウザに配る前提の「公開鍵」

anon(匿名)キーは、ブラウザに埋め込まれることを前提に設計された公開鍵です。Supabase公式ドキュメントは、このキーについて「クライアント側のコードで安全に使える。ただしそれはRow Level Securityを有効にしている場合に限る」と明言しています(Supabase: API Keys)。

ここが最初の落とし穴です。「anon は公開してよい」だけが独り歩きすると、「RLSが効いている場合に限る」という条件が抜け落ちます。 RLSを張り忘れたテーブルがあれば、その公開鍵は「誰でも入れる玄関の鍵」に変わります。

# RLS未設定のテーブルは、公開鍵 (anon) だけで誰でも全件読める
curl "https://<project-ref>.supabase.co/rest/v1/profiles?select=*" \
  -H "apikey: <anon-key-これは公開情報>"
# → RLS が無ければ全ユーザーの氏名・電話番号・stripe_customer_id が返る

anon キーが「公開してよい」のは、RLSが防壁として立っているからこそです。この前提が崩れる典型例——RLSの有効化漏れや using (true) のような無条件許可——はRLS設定ミスの検出ガイドで詳しく扱っています。鍵とRLSは、必ずセットで考えてください。

service_role キー:RLSを飛び越える「サーバー専用の秘密鍵」

service_role キーは、その対極にあります。Supabase公式は「このキーはRow Level Securityを完全にバイパスする。サーバー側でのみ使うこと」と繰り返し警告しています(Supabase: API Keys)。

技術的には、service_role キーで認証したリクエストは、PostgreSQL上の service_role というロールで実行されます。このロールは BYPASSRLS 属性を持っています。PostgreSQL公式ドキュメントの定義はこうです——「スーパーユーザー、および BYPASSRLS 属性を持つロールは、テーブルにアクセスする際つねに行セキュリティシステムをバイパスする」(PostgreSQL: Row Security Policies)。

実際のクライアント生成コードで、この2つを並べてみます。

// (1) ブラウザ/ユーザーセッションで動くクライアント — anon(publishable)キー
//     RLS が効く。ブラウザに配ってよい鍵。
import { createBrowserClient } from "@supabase/ssr";

export const supabase = createBrowserClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // ← NEXT_PUBLIC_ でOK(公開前提)
);
// (2) サーバー専用クライアント — service_role(secret)キー
//     RLS を完全にバイパスする。絶対にブラウザへ出さない。
import "server-only"; // ← クライアントから import したらビルドエラーになる(後述)
import { createClient } from "@supabase/supabase-js";

export function createAdminClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!, // ← NEXT_PUBLIC_ を絶対に付けない
    { auth: { persistSession: false, autoRefreshToken: false } },
  );
}

(1) は「ユーザーの権限を背負ったクライアント」、(2) は「全権を持つ管理者クライアント」です。同じ @supabase のAPIに見えて、効くガードがまったく違います。

補足:鍵の命名は進化しています。Supabaseは新形式として publishable key(sb_publishable_...secret key(sb_secret_... を導入しており、前者がブラウザ安全(旧 anon 相当)、後者がサーバー専用(旧 service_role 相当)です(Supabase: API Keys)。名前は変わっても境界は同じ——「ブラウザに出してよい鍵」と「サーバーから出してはいけない鍵」の2分法は不変です。本記事では従来名の anon / service_role で統一します。


3. なぜ service_role の漏洩は「即死」なのか——BYPASSRLSはRLSを飛び越える

RLSは「効く相手」と「効かない相手」がいる

ここを正確に理解することが、すべての設計判断の土台になります。RLSは万能の壁ではなく、ロールによって効いたり効かなかったりします。 PostgreSQL公式ドキュメントの記述を整理すると、こうです(PostgreSQL: Row Security Policies)。

  • スーパーユーザー / BYPASSRLS ロール → つねにRLSをバイパスする(= service_role はここ)
  • テーブルの所有者 → 通常はRLSをバイパスする。ただし FORCE ROW LEVEL SECURITY を設定すれば所有者にもRLSを適用できる
  • それ以外の一般ロール → RLSが効く(= anon / authenticated はここ)

つまり、anon キーで来たリクエストにはRLSが効き、service_role キーで来たリクエストには効きません。同じテーブル・同じポリシーでも、どの鍵で来たかで結果が変わるのです。

FORCE ROW LEVEL SECURITY でも service_role は止められない

「所有者バイパスが怖いなら FORCE を付ければいい」と考えるかもしれません。実際、所有者バイパスを塞ぐのには有効です。

alter table invoices enable row level security;
alter table invoices force row level security; -- テーブル所有者にもRLSを強制する

create policy "owners read their invoices"
on invoices for select to authenticated
using ( (select auth.uid()) = user_id );

しかし重要な但し書きがあります。FORCE ROW LEVEL SECURITY は、テーブル所有者には効いても BYPASSRLS ロール(= service_role)には効きません。 PostgreSQLの仕様上、BYPASSRLS は「つねにバイパスする」のであって、テーブル側の設定では覆せないからです。だから「DB側の設定で service_role を縛る」ことは原理的にできません。service_role を制御する唯一の方法は、その鍵を持つコードを物理的にサーバー内に閉じ込めること——これに尽きます。

「即死」の正体:NEXT_PUBLIC_ 混入とクライアントバンドルへの焼き込み

では、サーバー専用のはずの service_role が、どうやってブラウザに漏れるのか。Next.jsで最も多い事故は次の2つです。

// ❌ 事故その1:秘密鍵に NEXT_PUBLIC_ を付ける
//    → Next.js は NEXT_PUBLIC_ で始まる環境変数をビルド時にクライアントバンドルへ焼き込む
const admin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!, // ← 公開JSに混入=全データ露出
);
// ❌ 事故その2:"use client" のファイルでサーバー専用クライアントを import する
//    → モジュールごとブラウザに送られ、秘密鍵が同梱される
"use client";
import { createAdminClient } from "@/lib/supabase/admin";

export function Dashboard() {
  const admin = createAdminClient(); // クライアントで全権クライアントが動く=即アウト
  // ...
}

事故その1の恐ろしさは、ローカルでもデモでも何のエラーも出ないことです。動くし、速いし、RLSに悩まなくて済む。だからAIエージェントも急いでいる開発者も、つい NEXT_PUBLIC_ を付けてしまう。そしてビルド成果物に秘密鍵が焼き込まれたまま本番デプロイされます。攻撃者はブラウザの開発者ツールでJSバンドルを開き、eyJ... で始まる文字列を1つ拾うだけです。そこから先は、RLSもポリシーも一切関係なく、全テーブルが読み書き自由になります。

漏洩した service_role キーで攻撃者は何ができるか

抽象論ではなく、流出した秘密鍵を握った攻撃者の手元で実際に起きることを示します。SupabaseはPostgRESTで全テーブルをREST APIとして公開しているため、鍵さえあればクライアントライブラリすら不要です。

# 流出した service_role キーがあれば、RLS を無視して全テーブルを全件読める
curl "https://<project-ref>.supabase.co/rest/v1/invoices?select=*" \
  -H "apikey: <leaked-service-role-key>" \
  -H "Authorization: Bearer <leaked-service-role-key>"

# 読むだけではない。管理者として書き換え・削除も通る
curl -X DELETE "https://<project-ref>.supabase.co/rest/v1/invoices?id=eq.1025" \
  -H "apikey: <leaked-service-role-key>" \
  -H "Authorization: Bearer <leaked-service-role-key>"

ユーザーIDも、所有権も、ポリシーも一切問われません。service_role は「全テーブルに対する読み書きの全権」だからです。auth.users から他人のメールアドレスを引くことも、課金テーブルを書き換えることもできます。

service_role の漏洩が「情報漏洩」ではなく「即死」と呼ぶべきなのは、被害が単一テーブルや単一ユーザーに留まらず、データベース全体に及ぶからです。1本のミスが、すべてのRLSを無意味にします。だからこそ、検出も復旧も「漏れてから」では遅く、漏らさない設計(第5節)が本質になります。


4. service_role を"正しく"使うときの掟——所有権は全部コードの責任

service_role は「使ってはいけない鍵」ではありません。管理画面、バッチ処理、Webhook受信、複数ユーザーをまたぐ集計など、RLSを越える正当な用途があります。問題は「使うこと」ではなく「RLSという安全網を外したまま、所有権チェックを書き忘れること」です。

掟は2つだけです。(1) サーバー(Route Handler / Server Action)の中だけで使う。(2) 所有権を必ずコードで強制する。 RLSが効かないぶん、認可は100%自分の責任になります。

脆弱なコード:service_role × 所有権チェックなし

// app/api/invoices/[id]/route.ts — 脆弱(IDOR)
import { createAdminClient } from "@/lib/supabase/admin";

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;          // ← クライアントが自由に変えられる値
  const supabase = createAdminClient(); // ← RLS を飛び越える鍵

  const { data, error } = await supabase
    .from("invoices")
    .select("*")
    .eq("id", id) // ← 所有権の条件が一切ない
    .single();

  if (error) return Response.json({ error: error.message }, { status: 404 });
  return Response.json(data); // 他人の請求書もそのまま返る
}

このコードは、invoices に完璧なRLSポリシーが張ってあってもまったく効きません。 service_role がRLSを飛び越えるからです。攻撃は単純で、/api/invoices/1024/api/invoices/1025 に書き換えるだけ。これがOWASP API Security Top 10で第1位に位置づけられる API1:2023 Broken Object Level Authorization(BOLA/IDOR) です(OWASP API1:2023)。この欠陥の発見・修正に絞った解説はIDOR専用ガイドを参照してください。

修正案A(推奨):そもそも service_role を使わず、RLSに認可を任せる

最も堅牢なのは、この経路で service_role を使わないことです。ユーザーのセッションで動く anon キーのクライアントに切り替えれば、認可はDBのRLSが強制し、万一 .eq("user_id", ...) を書き忘れても安全側に倒れます(最後の砦がDBになる)。

// app/api/invoices/[id]/route.ts — 修正案A:anon キー+RLS に認可を任せる
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;
  const cookieStore = await cookies();

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // ← anon キー=RLS が効く
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: () => {}, // 読み取り専用ルートなので省略
      },
    },
  );

  // RLS が「自分の行」しか返さないため、ID を書き換えても他人の行は 0 件になる
  const { data, error } = await supabase
    .from("invoices").select("*").eq("id", id).single();

  if (error) return Response.json({ error: "not found" }, { status: 404 });
  return Response.json(data);
}

対応するRLSポリシー:

alter table invoices enable row level security;

create policy "owners read their invoices"
on invoices for select to authenticated
using ( (select auth.uid()) = user_id );

念のため——このポリシーに service_role を足しても無意味です。Supabase公式が明言するとおり、service_role はRLSの中で動くのではなくRLSそのものを飛び越えるため、ポリシーに書いても何の効果もありません(Supabase: Row Level Security)。service_role を制御したいなら、ポリシーではなく「鍵の置き場所」で対処するしかありません(第5節)。

修正案B:service_role が必要なら、所有権をコードで"必ず"強制する

管理画面やバッチなど、本当にRLSを越える必要がある経路もあります。その場合は、「誰なのか」をサーバーで確定し、所有権をWHERE句で必ず縛る——この2点を規律にします。

// app/api/invoices/[id]/route.ts — 修正案B:本人を確定し、所有権を WHERE で強制
import { createAdminClient } from "@/lib/supabase/admin";
import { getAuthenticatedUser } from "@/lib/auth";

export async function GET(
  req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;

  // 1) 誰なのかをサーバーで確定する(クライアントの自己申告を信じない)
  const user = await getAuthenticatedUser(req);
  if (!user) return Response.json({ error: "unauthorized" }, { status: 401 });

  // 2) service_role でも、所有権を必ず WHERE で縛る
  const supabase = createAdminClient();
  const { data, error } = await supabase
    .from("invoices")
    .select("*")
    .eq("id", id)
    .eq("user_id", user.id) // ← この1行が無いと IDOR
    .single();

  if (error || !data) return Response.json({ error: "not found" }, { status: 404 });
  return Response.json(data);
}

修正案Bは正しく動きますが、脆いことを自覚すべきです。.eq("user_id", user.id) を書き忘れた瞬間に穴が開き、コンパイラもRLSも助けてくれません。だから原則は修正案A(DBのRLSに認可を寄せる)。修正案Bは「越える正当な理由がある経路だけ、レビューで重点監視する例外」という位置づけにします。

マルチテナントSaaSは特に注意。 user_id だけでなく tenant_id / organization_id でも縛る必要があります。service_role 経路で tenant_id の絞り込みを忘れると、隣のテナントの全データが漏れます。テナント越え(cross-tenant leak)の検証手順はマルチテナント検証ガイドにまとめています。

Server Actions も同じ穴に落ちます。 "use server" のアクションは実質POSTエンドポイントで、引数や formData.get("id") で受け取るIDは等しくクライアント操作可能です。「画面に出していないIDだから安全」は成立しません。service_role を使うなら、Route Handlerと同じ所有権ルールを必ず適用してください。


5. 鍵の置き場所の設計——環境変数の境界と NEXT_PUBLIC_ の罠

第3節で見たとおり、service_role をDB側で縛ることはできません。守れるのは「置き場所」だけです。ここを設計として固めます。

環境変数の境界:NEXT_PUBLIC_ が"クライアントに出る/出ない"の唯一の判定

Next.jsのルールは明快です。NEXT_PUBLIC_ で始まる環境変数だけがクライアントバンドルに焼き込まれ、それ以外はサーバーにしか存在しません。 だから命名がそのまま境界になります。

# .env.local(.gitignore に入れ、絶対にコミットしない)

# 公開してよい — ブラウザに焼き込まれる前提
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<publishable-key-公開してよい>

# 絶対に公開しない — NEXT_PUBLIC_ を付けない=サーバーにしか出ない
SUPABASE_SERVICE_ROLE_KEY=<secret-key-絶対に貼らない>

SUPABASE_SERVICE_ROLE_KEYNEXT_PUBLIC_ を付けるか付けないか——たった接頭辞1つが、「サーバーの金庫」と「全世界に公開」を分けます。これが第3節の「事故その1」の正体です。

server-only 境界:import の時点で物理的に止める

命名規律は人間が間違えます。だから、機械的に止める仕組みを二重で入れます。Next.jsの server-only パッケージは、そのモジュールがクライアントコンポーネントから import された瞬間にビルドを失敗させます。

// lib/supabase/admin.ts
import "server-only"; // ← "use client" 配下から import されると即ビルドエラー
import { createClient } from "@supabase/supabase-js";

export function createAdminClient() {
  const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
  if (!key) throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set"); // 起動時に気づく
  return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, key, {
    auth: { persistSession: false, autoRefreshToken: false },
  });
}

これで「事故その2(クライアントからの import)」がビルド時に潰れます。service_role を触るコードは、必ずこの server-only 境界の内側に閉じ込める——これを規約にします。

漏洩の事後確認:ビルド成果物に秘密鍵の"値"が残っていないか

設計したら、検証します(verification first)。クライアントへ配られる静的アセットに、秘密鍵のが混入していないかを実際に確かめます。

# ビルド後、クライアント静的アセットに秘密鍵の"値"が混入していないか確認
npm run build
grep -rF "$SUPABASE_SERVICE_ROLE_KEY" .next/static \
  && echo "DANGER: 秘密鍵がバンドルに混入。直ちにローテーション" \
  || echo "OK: 静的バンドルに秘密鍵の値は無い"
# 注: $SUPABASE_SERVICE_ROLE_KEY をシェルに読み込んだ上で実行。値はログに出さない。

.next/server(サーバー側チャンク)に含まれるのは正常です。問題は .next/static(ブラウザへ配られる)に出ているかどうか。1件でもヒットしたら、その鍵はもう漏れたものとして扱い、Supabaseダッシュボードで即ローテーションしてください。

秘密の混入は「機械的に検出できる」領域

ここは朗報です。NEXT_PUBLIC_ への秘密鍵の付け間違いや、リポジトリにコミットされた秘密情報(鍵・トークン)は、データモデルを知らなくても形(パターン)だけで検出できる——つまり自動化が効く領域です。多くのOSSスキャナが秘密検知(secret scanning)を備えており、私が公開している Aegisnpx @aegiskit/cli scan でこの種の「NEXT_PUBLIC_ × 秘密鍵」「コミットされた秘密」を検出ルールに含めています。次節で見る認可(所有権)の正しさとは性質が違い、ここは機械に任せて取りこぼしを潰すべきところです。


6. 鍵とRLSは一体で考える——CVE-2025-48757が示すもの

鍵の議論を「RLSとは別の話」として切り離すと、必ず穴が空きます。両者は一体です。これを現実のインシデントで確認します。

2025年に登録された CVE-2025-48757 は、その典型です。NVDの公式記述はこうです——「Lovable(2025-04-15まで)の不十分なRow-Level Securityポリシーにより、リモートの未認証攻撃者が、生成されたサイトの任意のDBテーブルを読み書きできる」。分類は CWE-863(Incorrect Authorization、不正な認可)、CVSS基本値は 9.3 CRITICAL です。

このCVEは「RLSが不十分だった」ケースですが、本記事の文脈で読み替えると示唆は明確です。

  • RLSが無い/弱いテーブルは、公開鍵(anon)だけで誰でも触れる。 = 第2節の「公開鍵が玄関の鍵になる」事故。
  • そこに service_role 経路が1本でもあれば、RLSを完璧に張ってもその経路だけ全通し。 = 第4節のIDOR。

つまり、「鍵をどこに置くか」と「RLSをどう張るか」は、片方だけ正しくても守れません。 公開鍵を配るからにはRLSが必要で、service_role を使うならRLSが無い前提で所有権を書く。鍵とRLSは、つねにセットで設計判断するべきものです。

注目すべきは、このCVEがベンダーから**「disputed(係争中)」**とされ、プラットフォーム側が「アプリのデータ保護は利用者の責任」と主張している点です。是非はともかく、**事実として、鍵の管理と認可はプラットフォームが肩代わりしてくれない"あなたの責任範囲"**だということを、このCVEは雄弁に語っています。


7. 鍵運用チェックリスト

外注したコードでも、AIに書かせたコードでも、本番投入の前に最低限これだけは確かめてください。専門家でなくても判断できるよう、観点と危険信号をまとめます。

観点確認すること危険信号
service_roleの所在秘密鍵がクライアント/ブラウザに渡っていないかフロントのコードやネットワークタブ、JSバンドルに service_role の値が見える
NEXT_PUBLIC_ の付け間違い秘密鍵に NEXT_PUBLIC_ が付いていないかNEXT_PUBLIC_..._SERVICE_ROLE_KEY.env に存在する
import 境界秘密鍵を触るモジュールに server-only があるか"use client" から admin クライアントを import している
anonキーとRLS公開鍵で晒される全テーブルにRLSが有効かenable row level security の無いテーブルがある
service_role経路の所有権service_role を使うAPIで user_id / tenant_id の所有権条件があるかIDを受け取って .eq("id", ...) だけで返している
ビルド成果物の確認.next/static に秘密鍵の値が混入していないかビルド後の静的アセットを一度もgrepしたことがない
コミット履歴秘密鍵がgit履歴に残っていないか.env をコミットした形跡がある/鍵をローテーションしていない
ローテーション運用漏洩時に鍵を差し替える手順があるか「漏れたらどうするか」を決めていない

発注者の視点で最も効くのは、**「service_roleキーはどこで使っていますか?」「他人のIDを叩いたらどうなりますか?」**の2問です。明確に答えられないなら、鍵と認可の設計が固まっていない可能性が高い。良い開発者は、これらに即答できます。


8. 自分でやる範囲と、監査に出す範囲(正直に)

最後に、何を自動化でき、何ができないかを正直に切り分けます。ここを曖昧にする"魔法の製品"こそ危険だからです。

機械的に検出できる(自動化が効く)こと:

  • 秘密鍵の混入——NEXT_PUBLIC_ への付け間違い、コミットされた秘密、クライアントバンドルへの焼き込み
  • RLSの設定ミス——RLS未有効、using (true) の無条件許可、書き込みポリシーの WITH CHECK 欠落
  • service_role 経路の所有権欠落の"疑い"——汚染された入力が所有権スコープなしでクエリに到達するデータフロー

これらは形(パターン)で拾えるので、OSSスキャナで取りこぼしを潰すのが合理的です。インストール不要で試せます。

# インストール不要・設定不要でスキャン(秘密混入・RLS・所有権欠落の疑いを検出)
npx @aegiskit/cli scan

機械では証明できない(人間の判断が要る)こと:

  • どの経路で anon を使い、どの経路で service_role を使うべきかという設計判断
  • 「誰が何を所有するか」という、あなたの事業固有のデータモデルと認可ルール
  • service_role を使う正当な理由があるか、その経路のレビュー強度

正直に言えば、いかなるツールも「あなたの認可が正しいこと」や「完全に安全であること」を証明はできません。 ツールが見ているのは鍵やポリシーの"形"であって、あなたの業務ルールの"意味"ではない。クリーンな結果は「よくある罠は踏んでいない」であって、「安全だ」ではありません。だから自動検出は、人間によるレビューと脅威モデリングを置き換えるものではなく、補完するものとして位置づけます。

設計判断や既存アプリの鍵・認可レビューまで踏み込んで第三者の目を入れたい場合は、Aegisの監査メニューで対応しています。なお、こうした「鍵とRLSと所有権を一体で固める」設計は、木材流通DXのB2B SaaS事例のような、テナントと課金が絡む本番システムで実際に運用してきた領域です。


よくある質問(FAQ)

Q. anonキーをGitHubに公開しても大丈夫ですか? A. RLSが全テーブルで正しく効いていれば、anon キーは公開前提の鍵なので致命傷にはなりません(Supabase: API Keys)。ただし「RLSが効いている」が絶対条件です。RLSを張り忘れたテーブルが1つでもあれば、その公開鍵は誰でも入れる玄関になります。鍵の公開可否は、つねにRLSの状態とセットで判断してください。

Q. service_roleキーを誤ってコミット/公開してしまいました。 A. その鍵はもう漏れたものとして扱ってください。git履歴から消すだけでは不十分です(誰かが既に取得している前提)。Supabaseダッシュボードで即ローテーションし、旧鍵を無効化します。そのうえで、なぜ漏れたか(NEXT_PUBLIC_ 付け間違い/server-only 境界の欠如/.env のコミット)を特定し、再発防止の仕組みを入れます。

Q. service_roleキーは絶対に使ってはいけませんか? A. いいえ。管理処理・バッチ・Webhookなど正当な用途があります。鉄則は2つ——(1) サーバー側だけで使う(絶対にブラウザに出さない)、(2) 所有権をコードで必ず強制する。この2つを守れない経路では使わないでください。原則は「anon+RLSに寄せ、越える理由がある経路だけ service_role」です。

Q. RLSを完璧に張れば、鍵の管理は雑でもいいですか? A. いいえ。service_role はRLSを完全にバイパスします(PostgreSQL: Row Security)。FORCE ROW LEVEL SECURITY ですら BYPASSRLS ロールには効きません。RLSが完璧でも、秘密鍵が1本漏れれば全部通ります。RLSと鍵管理は、どちらか一方では成立しない両輪です。

Q. 個人開発や小規模でも、ここまでやるべきですか? A. むしろAIで素早く作ったアプリこそ事故が多い、というのがCVE-2025-48757などの示す現実です。最小構成でも 「秘密鍵を server-only の内側に閉じ込める」+「公開鍵で晒す全テーブルにRLS」+「service_role 経路の所有権チェック」 の3点だけは必ずやってください。コストはわずかで、防げる事故の大きさは桁違いです。


まとめ:守りの境界は「鍵の置き場所」と「所有権の置き場所」へ

要点を整理します。

  • anon キー(publishable)は公開してよい鍵。ただし「RLSが全テーブルで効いている場合に限る」。RLSが無ければ公開鍵は誰でも入れる玄関になる。
  • service_role キー(secret)は BYPASSRLS 権限で動き、RLSを完全に無視する。漏洩すれば全テーブルが露出する"即死"の鍵。NEXT_PUBLIC_ を付ける/クライアントから import するだけで漏れる。
  • DB側では service_role を縛れない。 FORCE ROW LEVEL SECURITY すら BYPASSRLS には効かない。守れるのは「鍵の置き場所」——サーバー限定・server-only 境界・NEXT_PUBLIC_ を付けないこと——だけ。
  • service_role を使う経路では、RLSが効かないぶん所有権チェックは100%コードの責任.eq("user_id", user.id) の1行が認可の最後の砦になる。
  • 鍵とRLSは一体で設計する。CVE-2025-48757が示すとおり、片方だけ正しくても守れないし、プラットフォームは肩代わりしてくれない。
  • 正直に言えば、ツールができるのは秘密混入やRLS設定ミスの"検出"まで。どの鍵をどこで使い、誰が何を所有するかという設計判断は人間の仕事。

鍵の扱いは、Supabaseアプリのセキュリティで最も事故が多く、最も取り返しがつかない一点です。AIで速く作ること自体は正しい。速く作ったものを、漏らさず安全に固める——その鍵運用と認可の設計レビュー、あるいは既存のNext.js × Supabaseアプリの監査が必要であれば、お気軽にご相談ください。


参考資料

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の脆弱性、あなたのアプリは大丈夫ですか?

Next.js × Supabase の認可・RLS を、専門家が監査します

この記事で扱った IDOR・RLS の設計ミス・テナント越境は、ライブラリでは直せない「縦のリスク」です。認可レビューから修正設計・実装まで、セキュリティ監査として承ります。まず無料の OSS で現状を可視化してからでも構いません。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。