# Next.js × Supabase アプリケーションセキュリティ完全ガイド — 脆弱性の検出と多層防御で、認可・RLSを守る

> AI量産のNext.js × Supabaseアプリのセキュリティ全体像。自動化できる水平統制（CSP・レート制限・CSRF・Zod検証）、静的解析で検出する注入（SQLi/SSRF/XSS）、設計でしか塞げない垂直リスク（認可/IDOR・RLS・テナント分離）に分け、検出の3層と多層防御で守る方法を体系化します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Next.js, Supabase, RLS, セキュリティ, TypeScript, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/nextjs-supabase-application-security-guide
- カテゴリ: アプリ層セキュリティ

## 要点

- アプリ層セキュリティは2種類に分かれる——ライブラリ/設定で一律に塞げる『水平統制』（CSP・レート制限・CSRF・Zod検証・秘密情報衛生）と、データモデルを知る人間にしか設計できない『垂直リスク』（認可/IDOR・RLS・テナント分離・業務ロジック）
- AI生成コードは『動くが安全ではない』。Veracodeの実測では45%が既知の脆弱性を含み、モデルが賢くなっても成績は横ばい。CVE-2025-48757（CVSS 9.3）はRLS不備で未認証アクセスを許した実例
- 注入クラス（SQLi・SSRF・パストラバーサル・オープンリダイレクト・DOM XSS）は『汚染入力→危険シンク』のデータフローを追う静的解析（taint）で機械的に検出できる
- 認可/IDORはWAFやヘッダーで防げない。攻撃が認証も書式も正しい『正規リクエスト』で、攻撃か否かはあなたの業務ロジックだけが決めるから。検出はSAST/RLS検証/DASTの3層で相関させる
- 自動化できるのは水平統制の実装と、垂直リスクの『検出・警告』まで。認可やRLSの設計修正は人間の設計判断＝監査領域であり、いかなるツールも認可の『正しさ』は証明しない

---

最初に結論を述べます。**Next.js × Supabase のアプリケーションセキュリティは、「自動化できる水平統制」と「設計でしか守れない垂直リスク」の2つに分けて地図を描くと、一気に見通しが良くなります。** 前者はセキュリティヘッダー・CSP・レート制限・CSRF・入力検証・秘密情報の衛生で、ライブラリと設定で**一律に**塞げます。後者は認可（IDOR）・RLSの設計・テナント分離・業務ロジックで、**あなたのデータモデルを知る人間にしか設計できません。**

この区別が重要なのは、世の中の「セキュリティ製品を入れれば安全」という発想が、**水平統制にしか効かない**からです。垂直リスクは、認証も書式も完全に正しい「正規のリクエスト」として現れるため、WAFやヘッダーでは構造的に防げません。本記事は、両者の境界を引き直したうえで、何を自動化で潰し、何を設計と検証で守るのかを、実コードと公開された一次情報に基づいて体系化します。これは「AIを使うな」「Supabaseは危ない」という話ではありません。**AIで速く作ること自体は正しい。速く作ったものを、漏らさず安全に固める仕組み**の話です。

---

## 1. 全体像：水平統制・注入クラス・垂直リスクの3層地図

細部に入る前に、地図を渡します。アプリ層の脅威は、性質と「自動化がどこまで届くか」で3つに分かれます。

| 層 | 代表的な脅威 | 主な防ぎ方 | 自動化の到達点 |
|---|---|---|---|
| **水平統制** | ヘッダー欠落、CSP不備、レート制限なし、CSRF、秘密情報漏洩、env不備 | ライブラリ・設定・ミドルウェアで一律適用 | **実装まで**自動化できる |
| **注入クラス** | SQLi、SSRF、パストラバーサル、オープンリダイレクト、DOM XSS | 入力の検証・無害化＋安全なAPI | **静的解析（taint）で検出**できる |
| **垂直リスク** | 認可/IDOR、RLS設計ミス、テナント越え、業務ロジック、権限昇格 | セキュアな設計＋検証（RLS＋所有権） | **検出・警告まで**。修正＝設計は人間 |

上の2層（水平統制・注入クラス）は、人間が毎回考えるべきものではありません。**設定で一度固め、静的解析で機械的に番をさせる**のが正解です。問題は3層目です。垂直リスクは「誰が何を所有するか」というアプリ固有の意味に依存するため、ツールは「怪しい」と指摘できても、「正しい」とは言えません。

この記事全体を貫く規律は、OWASPの **[Application Security Verification Standard（ASVS）](https://owasp.org/www-project-application-security-verification-standard/)** が示す思想と同じです——セキュリティは「入れたか」ではなく「**検証できるか**」で測る。以降、各層について「どう塞ぐか」と「どう検証するか」をセットで述べます。

---

## 2. 脅威の前提：AIが量産するNext.js × Supabaseの穴

なぜ今この地図が必要なのか。AI生成コードのセキュリティについては、推測ではなく実測があります。Veracodeが100以上のLLMに4言語・80のコーディングタスクを課した2025年の調査では、**生成コードの45%が既知のセキュリティ欠陥を含み、安全だったのは55%**でした。さらに重要なのは、**モデルが大きく賢くなっても、セキュリティの成績は横ばいだった**ことです（[Veracode 2025 GenAI Code Security Report](https://www.veracode.com/resources/analyst-reports/2025-genai-code-security-report/)）。コードは「より動く」ようにはなったが、「より安全」にはなっていない。

理由は単純です。AIは、プロンプトで指示された「やりたいこと（＝デモで動くこと）」を最短距離で実現します。「他人のデータを見せない」「内部資源を叩かせない」は、明示的に要求されない限りハッピーパスの外側にあり、**デモでは絶対に顕在化しません。** 自分のアカウントで触っている限り、IDを書き換えたり、`url=` に内部メタデータのアドレスを入れたりする操作は誰もしないからです。

これは机上の懸念ではありません。2025年に登録された **[CVE-2025-48757](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)** は、AI生成プラットフォーム（Lovable、2025-04-15まで）の**不十分なRow-Level Securityポリシー**により、リモートの**未認証**攻撃者が生成サイトの任意のDBテーブルを読み書きできた、というものです。分類は **CWE-863（Incorrect Authorization）**、CVSS基本値は **9.3 CRITICAL**。垂直リスクの欠陥が、最も普通に、最も深刻な形で本番に出た実例です。

> **「動いた＝安全」ではない。** これがこの記事の出発点です。AI生成コードを本番に出す前の堅牢化については、[AI生成コードの本番ハードニング](/blog/vibe-coding-ai-generated-code-production-hardening-guide)で別途まとめています。本記事はその「アプリ層」に絞った地図です。

---

## 3. 水平統制（自動化できる層）——設定で一度、固める

最初に潰すべきは水平統制です。アプリ横断で一律に効き、**ライブラリと設定で肩代わりできる**ため、費用対効果が圧倒的に高い。「人間が毎回正しく書く」ことを前提にしないのがコツです。

### 3-1. 型付きenv境界——秘密を境界で検証し、混入を防ぐ

環境変数は「外部入力」です。起動時に一度だけ検証し、以後は型安全に使います。秘密は `server-only` でクライアントバンドルへの混入を物理的に防ぎます。

```ts
// lib/env.server.ts — サーバー専用。クライアントに混入したらビルド時に弾く
import "server-only";
import { z } from "zod";

export const serverEnv = z
  .object({
    SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
    RESEND_API_KEY: z.string().startsWith("re_"),
  })
  .parse(process.env); // 起動時に1度。欠けていれば即クラッシュ＝fail-fast
```

`NEXT_PUBLIC_` 接頭辞の有無は重大です。**service_role キーを `NEXT_PUBLIC_` で公開すると全データが漏れます。** anon キーと service_role キーの責務分離は、それ自体が大きな主題なので[anonキーとservice_roleキーの扱い](/blog/supabase-anon-key-service-role-key-exposure-guide)で詳述しています。鍵の性質は[Supabase: API keys](https://supabase.com/docs/guides/api/api-keys)が一次情報です。

### 3-2. 入力検証（Zod）——システム境界で型を絞る

外部入力はすべて、ハンドラの入口で構造化検証します。「来た形」を信じない。

```ts
const Body = z.object({
  email: z.string().email(),
  phase: z.enum(["discovery", "build", "audit"]),
});

export async function POST(req: Request) {
  const parsed = Body.safeParse(await req.json());
  if (!parsed.success) return Response.json({ error: "invalid" }, { status: 400 });
  // 以後 parsed.data は型安全
}
```

### 3-3. セキュリティヘッダー / CSP（nonce）——XSSの被害を縮小する

CSPは「もしXSSが混入しても、外部スクリプトを実行させない」最後の壁です。`'unsafe-inline'` を避け、**リクエストごとに nonce を発行**するのが厳格版です。

```ts
// middleware.ts — リクエストごとに nonce を発行し、厳格な CSP を付与する
import { NextResponse, type NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self'`,
    `img-src 'self' data:`,
    `object-src 'none'`,
    `base-uri 'none'`,
    `frame-ancestors 'none'`, // クリックジャッキング対策
  ].join("; ");

  const headers = new Headers(request.headers);
  headers.set("x-nonce", nonce); // Server Component から読めるよう引き回す

  const res = NextResponse.next({ request: { headers } });
  res.headers.set("Content-Security-Policy", csp);
  return res;
}
```

合わせて `Strict-Transport-Security`、`X-Content-Type-Options: nosniff`、`Referrer-Policy` を付与します。これらは**一度書けば全リクエストに効く**典型的な水平統制です。

### 3-4. レート制限——サーバーレスでは「共有ストア」に状態を置く

ここはAI生成コードが最も間違える箇所です。サーバーレス関数は**リクエストごとに使い捨て**られるため、**プロセス内の `Map` カウンタは効きません**（別インスタンスで実行されればカウントがリセットされる）。状態は外部の共有ストアに置きます。

```ts
// レート制限：状態はプロセス外（Redis 等）へ。メモリ内カウンタはサーバーレスで無効
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "60 s"),
});

export async function POST(req: Request) {
  const ip = req.headers.get("x-forwarded-for") ?? "anonymous";
  const { success } = await ratelimit.limit(ip);
  if (!success) return new Response("Too Many Requests", { status: 429 });
  // 本処理…
}
```

### 3-5. CSRF / Origin 検証——状態変更は出所を確かめる

Server Actions や `POST` ルートのような状態変更経路は、`SameSite` Cookie に加えて **Origin を検証**して二段で守ります。

```ts
// 状態変更リクエストは Origin を検証する
const origin = req.headers.get("origin");
const allowed = new URL(process.env.NEXT_PUBLIC_SITE_URL!).origin;
if (req.method !== "GET" && origin !== allowed) {
  return new Response("Forbidden", { status: 403 });
}
```

ここまでが水平統制です。**いずれも「正しいライブラリ／設定を選ぶ」だけで肩代わりでき、アプリ固有の知識を要しません。** だからこそ、人間の脳ではなくCIに番をさせるべき領域です。

---

## 4. 注入クラス（静的解析で検出できる層）——汚染入力を追う

注入は「**クライアントが操作できる入力（汚染入力／source）が、検証されないまま危険な処理（シンク／sink）に到達する**」ときに起きます。共通の構造を持つため、正規表現ではなく**データフロー解析（taint解析）**で機械的に追えます。

| 汚染入力（source） | 危険シンク（sink） | 脆弱性クラス |
|---|---|---|
| `searchParams.get("q")` | 文字列連結したSQL / `rpc()` | SQLインジェクション |
| `searchParams.get("url")` | `fetch(url)` | SSRF |
| `params.file` | `fs.readFile(path)` | パストラバーサル |
| `searchParams.get("next")` | `redirect(next)` | オープンリダイレクト |
| リクエスト由来の文字列 | `dangerouslySetInnerHTML` | DOM / 格納型XSS |

### SQLインジェクション：文字列を組み立てず、値として渡す

Supabaseの構造化API（`.eq()` 等）は値をパラメータとして扱うため基本的に安全ですが、**生のフィルタ文字列を受け取る `.or()` や、`rpc()` 内で文字列連結したSQL**は注入経路になります。

```ts
// 脆弱：ユーザー入力を .or() のフィルタ文字列に直接連結（PostgRESTフィルタ injection）
const q = new URL(req.url).searchParams.get("q") ?? ""; // ← 汚染入力
const { data } = await supabase
  .from("posts")
  .select("*")
  .or(`title.ilike.%${q}%`); // q に区切り文字を仕込むと条件を崩し、意図しない行を引ける

// 修正：構造化APIで「値」として渡す（フィルタ文字列を組み立てない）
const { data: safe } = await supabase
  .from("posts")
  .select("*")
  .ilike("title", `%${q}%`); // q はパラメータ扱いになる
```

`SECURITY DEFINER` 関数の中で `EXECUTE 'select ... ' || input` のように動的SQLを組むのも同じ穴です。`format(..., %L)` やパラメータバインドを使い、入力を文字列連結しないのが鉄則です。

### SSRF：ユーザー指定URLにサーバーが到達してしまう

```ts
// 脆弱：汚染入力がそのまま fetch のシンクに届く（SSRF）
export async function GET(req: Request) {
  const url = new URL(req.url).searchParams.get("url")!; // ← 汚染入力
  const res = await fetch(url); // ← 危険シンク：169.254.169.254 等の内部資源に到達しうる
  return new Response(await res.text());
}
```

修正は「**許可ホストのallowlist＋プライベート/リンクローカルIP帯の遮断＋リダイレクト追従の無効化**」です。Supabaseのサーバー環境では、内部メタデータや他サービスへの到達が致命傷になります。

### オープンリダイレクト：戻り先をそのまま信じる

```ts
// 脆弱：next をそのまま信じてフィッシングへ誘導される
const next = new URL(req.url).searchParams.get("next") ?? "/";
redirect(next); // next="https://evil.example/login" でも飛ぶ

// 修正：相対パスだけ許可する（"//" 始まりはプロトコル相対なので除外）
redirect(next.startsWith("/") && !next.startsWith("//") ? next : "/");
```

### パストラバーサル：`../` でファイルツリーを抜ける

ユーザー入力をパスに連結してファイルを読むと、`../../` で想定ディレクトリの外（`.env` やキーファイル）に到達されます。

```ts
// 脆弱：汚染入力をそのままパスに連結（パストラバーサル）
const name = new URL(req.url).searchParams.get("file")!; // ← "../../.env" など
const buf = await fs.readFile(path.join("./uploads", name)); // 危険シンク

// 修正：basename で剥がし、解決後のパスが基底配下にあることを検証する
const base = path.resolve("./uploads");
const resolved = path.resolve(base, path.basename(name));
if (!resolved.startsWith(base + path.sep)) throw new Error("invalid path");
const safe = await fs.readFile(resolved);
```

DOM XSSも同型です。リクエスト由来の値を `dangerouslySetInnerHTML` に渡せば実行されます（サーバー計算済みのJSON-LD等、信頼できる出力に限定すべきシンクです）。

注入クラスの良いところは、**汚染源から危険シンクへの経路を追えば、アプリの業務知識なしで検出できる**点です。次節の垂直リスクと違い、ここはツールが本領を発揮します。

---

## 5. 垂直リスク（設計でしか塞げない層）——RLSと認可

ここが本記事の核心です。垂直リスクは「誰が何を所有するか」というアプリ固有の意味に依存するため、**ライブラリも設定もWAFも肩代わりできません。**

### 5-1. なぜWAF・ヘッダーで防げないのか

IDOR（OWASP **[API1:2023 Broken Object Level Authorization](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)**）を例にします。`GET /api/invoices/1024` を `/api/invoices/1025` に書き換えるだけで他人の請求書が返るなら、それがIDORです。BOLAは初版以来ずっと[OWASP API Security Top 10](https://owasp.org/API-Security/editions/2023/en/0x11-t10/)の**第1位**——最頻出のリスクです。

このリクエストは**HTTPとして完全に正常**です。認証ヘッダーも正しく、不正な文字列も含まない。WAFから見れば「正規ユーザーの正規リクエスト」です。攻撃になるのは、**「1025番がこのユーザーの所有物ではない」というアプリ固有の業務的事実**による——そしてWAFはあなたのデータモデルを知りません。IDORの発生機序と修正は[IDOR/認可欠陥の検出ガイド](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide)で深掘りしています。

### 5-2. RLSの設計ミス——「張った」と「効いている」は違う

SupabaseはPostgreSQLの行レベルセキュリティ（RLS）でデータ層から認可を強制できます（[Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security) / [PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)）。強力ですが、**「有効化した」ことと「正しく効いている」ことは別物**です。よくある設計ミスを挙げます。

```sql
-- アンチパターン集
using ( true )                              -- 無条件許可＝実質RLSなし
-- WITH CHECK の無い書き込みポリシー        -- INSERT/UPDATE で他人の行を作れる
-- SECURITY DEFINER 関数で search_path 未固定 -- 権限昇格の温床
-- anon ロールへの過剰な GRANT              -- 公開鍵で書き込めてしまう
```

これらの体系的な検出は[RLS設定ミスの検出と監査](/blog/supabase-rls-misconfiguration-detection-audit-guide)に、本番マルチテナントでの堅牢なポリシー設計は[本番RLSのマルチテナンシー設計](/blog/supabase-rls-production-multi-tenancy-patterns)にまとめています。

### 5-3. テナント分離——「根拠にする値」を間違えると全社漏れる

最も恐ろしいのは、ポリシーが構文的に正しいのに**根拠にしている値がユーザー操作可能**なケースです。SupabaseのJWTには `user_metadata`（ユーザー自身が更新できる）と `app_metadata`（サーバー側だけが書ける）があります。

```sql
-- 危険：user_metadata はユーザー自身が書き換えられる（クライアント操作可能）
create policy "tenant read"
on documents for select
to authenticated
using ( tenant_id = (auth.jwt() -> 'user_metadata' ->> 'tenant_id')::uuid );
-- → 攻撃者が自分の user_metadata.tenant_id を他社IDに書き換えれば、他テナントの行が見える

-- 修正：サーバーだけが書ける app_metadata を根拠にする
create policy "tenant read"
on documents for select
to authenticated
using ( tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid );
```

この違いはツールの構文チェックでは見抜きにくく、**「このシステムでテナントの帰属を決めるのは何か」という設計判断**そのものです。テナント越え漏洩の検証手順は[マルチテナントのクロステナント漏洩検証](/blog/supabase-multi-tenant-cross-tenant-leak-verification-guide)に切り出しています。

### 5-4. service_role はRLSを「飛び越える」

最後に、RLSが完璧でも全部無効化する経路があります。**service_role キーはPostgreSQLの `BYPASSRLS` 権限で動き、RLSを完全に無視します。** サーバー側で「確実に動くから」と service_role を使い、所有権チェック（`.eq("user_id", user.id)`）を書き忘れると、RLSの有無に関係なくIDORが開きます。守りの境界は「RLSがあるか」ではなく、**「service_roleをどこに置き、所有権をどこで強制するか」**に移ります。

```ts
// 脆弱：service_role でRLSを飛び越え、所有権チェックを忘れている（IDOR）
const supabaseAdmin = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!);

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params; // ← クライアントが自由に変えられる
  const { data } = await supabaseAdmin.from("invoices").select("*").eq("id", id).single();
  return Response.json(data); // RLSが完璧でも、service_role が飛び越えるので他人の行が返る
}

// 修正：service_role を使うなら、誰なのかを確定し所有権を必ず WHERE で縛る
const user = await getAuthenticatedUser(req);
if (!user) return new Response("unauthorized", { status: 401 });
const { data } = await supabaseAdmin
  .from("invoices").select("*")
  .eq("id", id).eq("user_id", user.id).single(); // ← この1行が無いとIDOR
```

原則は「**そもそも service_role を使わず、ユーザーのセッション（anon キー）で動かしてRLSに認可を任せる**」。越える理由がある経路だけ、上記のように所有権を強制し、レビューで重点監視します（詳細は前掲のIDORガイド）。

### 5-5. 業務ロジックの欠陥——「ありえない状態」を許す

垂直リスクの最後は、認可が通った正規ユーザーが**業務上ありえない入力で不正を行う**クラスです。これはRLSでもヘッダーでも防げず、ドメイン知識を持つ人間しか定義できません。

```ts
// 例：クライアントから送られた価格・数量をそのまま信じる
const { price, qty } = await req.json();
const total = price * qty; // price=0、qty=-1、過去の価格…をそのまま受理してしまう
```

「金額はサーバーで再計算する」「数量は正の整数に制約する」「状態遷移（下書き→請求→入金）は順序を強制する」——こうしたルールは**仕様であり、コードの外形からは導けません**。だからこそ自動化の限界が最も鋭く出る領域です。

---

## 6. 検出の3層——SAST / RLS検証 / DAST を相関させる

設計で塞ぐと決めたら、「塞げているか」を検証します。1つの手段に頼らず、静的・構造的・動的を重ねます。

### 層1：静的解析（SAST）——コードのデータフローを追う

汚染入力が「所有権で絞られないまま」DBクエリや危険シンクに到達していないかを、関数内のtaint解析で追います。正規表現では書けない検出です。私が公開しているOSS Aegis はこれを実装しており、インストール不要で走ります。

```bash
# インストール不要・設定不要でスキャン（汚染入力→危険シンクを可視化）
npx @aegiskit/cli scan
```

### 層2：SQL / RLS の検証——認可が「DBに」正しく宿っているか

コードとは別に `supabase/migrations/**.sql` を読み、RLS無効のテーブル、`using (true)`、`WITH CHECK` 欠落、`search_path` 未固定の `SECURITY DEFINER`、anon への過剰GRANTを洗い出します。さらに**SQLとコードを突き合わせ**、「RLSの弱いテーブルを非管理クライアントから実際にクエリしている箇所」を確定露出として指摘します。

### 層3：動的確認（DAST）——実際に「他人になって」叩く

静的解析は「疑う」ところまで。最後は**自分が所有するアプリ**でIDOR/テナント越えを再現して**確定**させます。2つのアイデンティティ（A・B）を用意し、Aのセッションのまま Bのリソースを叩いて200が返れば、実行時に確定です。

```bash
# 自分のアプリへの安全・非破壊なプローブ（所有権の食い違いを実行時に確認）
npx @aegiskit/cli probe http://localhost:3000 --correlate
```

静的の「疑い」と動的の「再現」が一致したものは **confirmed-exploitable** として最優先で直す——これがSAST↔DASTの相関です。退行を防ぐなら [pgTAPでRLSの回帰テスト](/blog/supabase-rls-testing-pgtap-policy-regression-guide)を書き、CIで「他人の行が見えないこと」を継続的に証明します。

```sql
-- pgTAP：別ユーザーのJWTで他人の行が見えないことを回帰テストにする
begin;
select plan(1);
set local role authenticated;
set local request.jwt.claims to '{"sub":"user-b-uuid"}';
select is_empty(
  $$ select * from invoices where user_id = 'user-a-uuid' $$,
  'user B cannot read user A invoices'
);
select * from finish();
rollback;
```

> **正直なスコープ。** いかなる静的・動的ツールも、あなたの認可が**正しいことは証明しません。** 見ているのはポリシーや実装の「形」であって、事業ルールやデータモデルの「意味」ではない。データフロー解析は関数内（intraprocedural）が基本で、モジュールやフレームワークを跨ぐ流れは見逃します。クリーンな結果は「よくある罠は踏んでいない」であって「安全」ではありません。**これらの検証は、人間のレビューと脅威モデリングを置き換えるものではなく、補完するものです。**

---

## 7. 多層防御マップ——どの層を、誰が守るか

ここまでを1枚にまとめます。重要なのは、**層が深くなるほど「守る主体」がプラットフォームから人間へ移る**ことです。

| 層 | 主な脅威 | 対策 | 誰が守るか |
|---|---|---|---|
| ネットワーク／エッジ | DDoS、ボット、既知の悪性IP | WAF、レート制限、Bot対策 | プラットフォーム／設定 |
| HTTP／ブラウザ | XSS、クリックジャッキング、MIMEスニッフィング | CSP(nonce)、各種セキュリティヘッダー | ライブラリ／ミドルウェア |
| 入力境界 | SQLi、SSRF、パストラバーサル、不正データ | Zod検証、安全なAPI、allowlist | 開発者＋静的解析 |
| 認証 | なりすまし、セッション固定 | Supabase Auth、SameSite Cookie | ライブラリ＋設定 |
| **認可（データ）** | **IDOR/BOLA、テナント越え** | **RLS＋コードの所有権チェック** | **人間の設計** |
| データ層 | RLSバイパス、鍵漏洩 | anon鍵運用、service_role隔離、pgTAP | 人間の設計＋検証 |

上半分（ネットワーク〜認証）は水平統制・注入クラスで、設定とライブラリで一律に固められます。**下半分（認可・データ層）が垂直リスク**で、設計と検証の両輪でしか守れません。「製品を入れたから安全」という話が成り立たないのは、最も深刻なリスクが、最も自動化しにくい場所にあるからです。

---

## 8. 本番前チェックリスト

外注でもAI製でも、本番投入の前に最低限これだけは確認してください。観点と危険信号を併記します。

- [ ] **全テーブルでRLSを有効化**し、ポリシーを明示している（有効化だけならデフォルト全拒否＝fail-secure）
- [ ] **service_role キーはサーバー専用**。`server-only` で混入を防ぎ、`NEXT_PUBLIC_` で公開していない
- [ ] service_role を使う経路は、**所有権を `WHERE` で必ず縛っている**（`.eq("user_id", user.id)`）
- [ ] **env を起動時にZod検証**し、秘密をクライアントバンドルに出していない
- [ ] 状態変更APIに **Origin検証＋レート制限**（共有ストア）を入れている
- [ ] **CSP(nonce)** と主要セキュリティヘッダーを付与している
- [ ] 注入シンク（`fetch`／`redirect`／`fs`／生SQL）に**汚染入力が素通りしていない**
- [ ] テナント分離は **`app_metadata` 等サーバー権限の値**に根拠を置いている（`user_metadata` ではない）
- [ ] **2アカウントでID差し替え／テナント越え**を実行時に確認した
- [ ] **SAST／RLS検証／回帰テスト(pgTAP)** をCIに常設している

発注者の視点で最も効くのは、**「他人のIDを叩いたらどうなりますか？」「service_roleキーはどこで使っていますか？」「テナントの帰属は何を根拠に決めていますか？」**の3問です。良い開発者は即答できます。

---

## 9. どこまで自分で、どこから監査か

最後に、正直に線を引きます。

**水平統制（第3節）と注入クラス（第4節）は、自動化で大半を機械的に潰せます。** 正しいライブラリと設定を選び、静的解析をCIに入れれば、人間が毎回考える必要はありません。まずは [Aegis](/aegis)（無料OSS、`npx @aegiskit/cli scan`）で現状を可視化するのが、最もコスパの良い第一歩です。

一方、**垂直リスク（第5節）の「検出・警告」までは自動化できても、「修正＝設計」は人間の領域です。** 認可の正しさ、テナント帰属の根拠、業務ロジックの妥当性は、あなたのデータモデルと事業ルールを理解した人間にしか判断できません。ここでツールが「安全だ」と言い切る製品は、むしろ危険です。**Aegisは水平統制の実装を助け、垂直リスクを検出・警告しますが、認可が正しいことは証明しません。完全に安全にする魔法はありません。**

だからこそ、線引きが要ります。**どこまで自分で固め、どこから専門家のレビューが要るか**——その判断基準は[セキュリティ監査が必要になる範囲](/blog/nextjs-supabase-security-audit-scope-when-needed-guide)に整理しました。垂直リスクの設計修正や、既存アプリの認可・RLSレビューが必要なら、[セキュリティ監査](/aegis/audit)で承ります。私自身、[木材流通業界のDX案件](/case-studies/lumber-industry-dx)で、RLS・テナント分離・所有権強制を含むアプリ層の認可を実運用で設計・検証してきました。

---

## よくある質問（FAQ）

**Q. まず何から手をつければいいですか？**
A. 順番があります。(1) RLS全有効化と service_role の隔離（垂直の土台）、(2) `npx @aegiskit/cli scan` で注入と水平統制の穴を可視化、(3) 2アカウントでのID差し替え確認。この3つは最小コストで最大の事故を防ぎます。

**Q. WAFやセキュリティヘッダーを入れれば足りますか？**
A. 足りません。第7節のとおり、それらは上半分（水平統制）に効くだけで、最も深刻な認可（IDOR・テナント越え）は「正規リクエスト」なので防げません。両方必要ですが、片方がもう片方を代替することはありません。

**Q. AIに「セキュアに書いて」と頼めば直りますか？**
A. 期待しすぎないでください。Veracodeの調査では、モデルが賢くなってもセキュリティの成績は横ばいでした。AIは「動くコード」の生成には強いが、「壊れない構造」を保証はしません。検証ゲート（スキャン・テスト・レビュー）を通して初めて本番品質になります。

**Q. RLSさえ張れば認可は安全ですか？**
A. いいえ。service_role 経路はRLSを飛び越え、`SECURITY DEFINER` 関数・RPC・Storage・外部結合先など効かない領域も残ります。さらに、根拠にする値（`user_metadata` か `app_metadata` か）を間違えれば、構文的に正しいRLSでも全社漏れます。RLSは必須の「最後の砦」ですが、コード側の所有権チェックと二層で守るのが正解です。

**Q. 個人開発や小規模でも、ここまでやるべきですか？**
A. むしろAIで素早く作ったアプリこそ露出例が多い、というのがCVE-2025-48757などの示す現実です。最小でも「全テーブルRLS」「service_role隔離＋所有権チェック」「2アカウントでのID差し替え確認」の3点だけは必ず。コストはわずかで、防げる事故は桁違いです。

---

## まとめ：地図を持てば、守りの優先順位が決まる

要点を整理します。

- アプリ層セキュリティは **水平統制・注入クラス・垂直リスク** の3層に分けて地図を描く。上2層は自動化で潰し、最下層は設計と検証で守る。
- **水平統制**（ヘッダー/CSP・レート制限・CSRF・Zod・env・秘密情報衛生）は、正しいライブラリと設定で**一律に肩代わりできる**。サーバーレスのレート制限は共有ストアに状態を置くのが肝。
- **注入クラス**（SQLi・SSRF・パストラバーサル・オープンリダイレクト・DOM XSS）は「汚染入力→危険シンク」のデータフローを追う**静的解析（taint）で機械的に検出できる**。
- **垂直リスク**（認可/IDOR・RLS設計・テナント分離・業務ロジック）は、認証も書式も正しい「正規リクエスト」として現れるため、**WAFやヘッダーでは防げない**。守りの境界は「RLSの有無」ではなく「鍵と所有権の置き場所」「テナント帰属の根拠」へ移る。
- 検出は **SAST／RLS検証／DAST** の3層で相関させる。ただし**ツールは発見を助けるだけで、認可の正しさは設計と人間のレビューでしか守れない**。

AIで速く作ること自体は正しい。**速く作ったものを、漏らさず安全に固める**——その仕組みづくりや、既存のNext.js × Supabaseアプリの認可・RLSレビューが必要であれば、お気軽にご相談ください。

---

## 参考資料

- [OWASP API Security Top 10（2023）](https://owasp.org/API-Security/editions/2023/en/0x11-t10/)
- [OWASP API1:2023 — Broken Object Level Authorization（BOLA/IDOR、API最頻出リスク）](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)
- [OWASP Application Security Verification Standard（ASVS）](https://owasp.org/www-project-application-security-verification-standard/)
- [NVD — CVE-2025-48757（不十分なRLSによる未認証アクセス、CWE-863、CVSS 9.3）](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)
- [Veracode — 2025 GenAI Code Security Report（AI生成コードの45%にセキュリティ欠陥）](https://www.veracode.com/resources/analyst-reports/2025-genai-code-security-report/)
- [Supabase Docs — Row Level Security（service_roleはRLSをバイパスする）](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [Supabase Docs — API keys（anon と service_role の違い）](https://supabase.com/docs/guides/api/api-keys)
- [PostgreSQL — Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
