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

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

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Supabase, RLS, Next.js, セキュリティ, TypeScript
- URL: https://tomodahinata.com/blog/supabase-anon-key-service-role-key-exposure-guide
- カテゴリ: アプリ層セキュリティ
- 総合ガイド: https://tomodahinata.com/blog/nextjs-supabase-application-security-guide

## 要点

- anonキー（publishable）はブラウザに配る公開鍵で『晒してよい』——ただしそれは全テーブルでRLSが効いている場合に限る。RLSが無ければ公開鍵は誰でも入れる玄関になる
- service_roleキー（secret）はPostgreSQLのBYPASSRLS権限で動き、RLSを完全に無視する。漏洩＝全テーブル露出。NEXT_PUBLIC_を付ける／クライアントからimportするだけで即死する
- service_roleを使う経路では、RLSが効かないぶん所有権チェックは100%コードの責任。`.eq("user_id", user.id)` の1行を忘れた瞬間に他人のデータが返る
- 守りの境界は『鍵の置き場所（サーバー限定・server-only境界）＋所有権チェック』に移る。FORCE ROW LEVEL SECURITY すら service_role は越える
- 正直に言えば、ツールができるのは秘密の混入やRLS設定ミスの『検出』まで。どの経路でどの鍵を使い、誰が何を所有するかという設計判断は人間の仕事

---

最初に結論を述べます。**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全体の防御設計は[総合ガイド](/blog/nextjs-supabase-application-security-guide)にまとめていますが、その中でも「鍵の扱い」は最も事故が多く、最も取り返しがつかない一点です。

---

## 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](https://supabase.com/docs/guides/api/api-keys)）。

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

```bash
# 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設定ミスの検出ガイド](/blog/supabase-rls-misconfiguration-detection-audit-guide)で詳しく扱っています。鍵とRLSは、必ずセットで考えてください。

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

`service_role` キーは、その対極にあります。Supabase公式は「このキーはRow Level Securityを**完全にバイパス**する。**サーバー側でのみ**使うこと」と繰り返し警告しています（[Supabase: API Keys](https://supabase.com/docs/guides/api/api-keys)）。

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

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

```ts
// (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（公開前提）
);
```

```ts
// (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](https://supabase.com/docs/guides/api/api-keys)）。名前は変わっても境界は同じ——「ブラウザに出してよい鍵」と「サーバーから出してはいけない鍵」の2分法は不変です。本記事では従来名の `anon` / `service_role` で統一します。

---

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

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

ここを正確に理解することが、すべての設計判断の土台になります。RLSは万能の壁ではなく、**ロールによって効いたり効かなかったりします。** PostgreSQL公式ドキュメントの記述を整理すると、こうです（[PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)）。

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

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

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

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

```sql
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つです。

```ts
// ❌ 事故その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に混入＝全データ露出
);
```

```tsx
// ❌ 事故その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として公開しているため、鍵さえあればクライアントライブラリすら不要です。

```bash
# 流出した 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 × 所有権チェックなし

```ts
// 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](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)）。この欠陥の発見・修正に絞った解説は[IDOR専用ガイド](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide)を参照してください。

### 修正案A（推奨）：そもそも service_role を使わず、RLSに認可を任せる

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

```ts
// 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ポリシー:

```sql
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](https://supabase.com/docs/guides/database/postgres/row-level-security)）。`service_role` を制御したいなら、ポリシーではなく「鍵の置き場所」で対処するしかありません（第5節）。

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

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

```ts
// 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）の検証手順は[マルチテナント検証ガイド](/blog/supabase-multi-tenant-cross-tenant-leak-verification-guide)にまとめています。

> **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_` で始まる環境変数だけがクライアントバンドルに焼き込まれ、それ以外はサーバーにしか存在しません。** だから命名がそのまま境界になります。

```bash
# .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_KEY` に `NEXT_PUBLIC_` を付けるか付けないか——たった接頭辞1つが、「サーバーの金庫」と「全世界に公開」を分けます。これが第3節の「事故その1」の正体です。

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

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

```ts
// 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）。クライアントへ配られる静的アセットに、秘密鍵の**値**が混入していないかを実際に確かめます。

```bash
# ビルド後、クライアント静的アセットに秘密鍵の"値"が混入していないか確認
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）を備えており、私が公開している [Aegis](/aegis) も `npx @aegiskit/cli scan` でこの種の「`NEXT_PUBLIC_` × 秘密鍵」「コミットされた秘密」を検出ルールに含めています。次節で見る認可（所有権）の正しさとは性質が違い、ここは機械に任せて取りこぼしを潰すべきところです。

---

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

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

2025年に登録された **[CVE-2025-48757](https://nvd.nist.gov/vuln/detail/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スキャナで取りこぼしを潰すのが合理的です。インストール不要で試せます。

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

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

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

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

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

---

## よくある質問（FAQ）

**Q. anonキーをGitHubに公開しても大丈夫ですか？**
A. RLSが全テーブルで正しく効いていれば、`anon` キーは公開前提の鍵なので致命傷にはなりません（[Supabase: API Keys](https://supabase.com/docs/guides/api/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](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)）。`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アプリの監査が必要であれば、お気軽にご相談ください。

---

## 参考資料

- [Supabase Docs — API Keys（anonは公開可・RLS前提／service_roleはサーバー専用）](https://supabase.com/docs/guides/api/api-keys)
- [Supabase Docs — Row Level Security（service_roleはRLSをバイパスする）](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [PostgreSQL Docs — Row Security Policies（BYPASSRLS / FORCE ROW LEVEL SECURITY）](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [NVD — CVE-2025-48757（Lovable / 不十分なRLSによる未認証アクセス、CWE-863、CVSS 9.3）](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)
- [OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)
