最初に結論を述べます。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) が示す思想と同じです——セキュリティは「入れたか」ではなく「検証できるか」で測る。以降、各層について「どう塞ぐか」と「どう検証するか」をセットで述べます。
2. 脅威の前提:AIが量産するNext.js × Supabaseの穴
なぜ今この地図が必要なのか。AI生成コードのセキュリティについては、推測ではなく実測があります。Veracodeが100以上のLLMに4言語・80のコーディングタスクを課した2025年の調査では、**生成コードの45%が既知のセキュリティ欠陥を含み、安全だったのは55%**でした。さらに重要なのは、モデルが大きく賢くなっても、セキュリティの成績は横ばいだったことです(Veracode 2025 GenAI Code Security Report)。コードは「より動く」ようにはなったが、「より安全」にはなっていない。
理由は単純です。AIは、プロンプトで指示された「やりたいこと(=デモで動くこと)」を最短距離で実現します。「他人のデータを見せない」「内部資源を叩かせない」は、明示的に要求されない限りハッピーパスの外側にあり、デモでは絶対に顕在化しません。 自分のアカウントで触っている限り、IDを書き換えたり、url= に内部メタデータのアドレスを入れたりする操作は誰もしないからです。
これは机上の懸念ではありません。2025年に登録された CVE-2025-48757 は、AI生成プラットフォーム(Lovable、2025-04-15まで)の不十分なRow-Level Securityポリシーにより、リモートの未認証攻撃者が生成サイトの任意のDBテーブルを読み書きできた、というものです。分類は CWE-863(Incorrect Authorization)、CVSS基本値は 9.3 CRITICAL。垂直リスクの欠陥が、最も普通に、最も深刻な形で本番に出た実例です。
「動いた=安全」ではない。 これがこの記事の出発点です。AI生成コードを本番に出す前の堅牢化については、AI生成コードの本番ハードニングで別途まとめています。本記事はその「アプリ層」に絞った地図です。
3. 水平統制(自動化できる層)——設定で一度、固める
最初に潰すべきは水平統制です。アプリ横断で一律に効き、ライブラリと設定で肩代わりできるため、費用対効果が圧倒的に高い。「人間が毎回正しく書く」ことを前提にしないのがコツです。
3-1. 型付きenv境界——秘密を境界で検証し、混入を防ぐ
環境変数は「外部入力」です。起動時に一度だけ検証し、以後は型安全に使います。秘密は server-only でクライアントバンドルへの混入を物理的に防ぎます。
// 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キーの扱いで詳述しています。鍵の性質はSupabase: API keysが一次情報です。
3-2. 入力検証(Zod)——システム境界で型を絞る
外部入力はすべて、ハンドラの入口で構造化検証します。「来た形」を信じない。
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 を発行するのが厳格版です。
// 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 カウンタは効きません(別インスタンスで実行されればカウントがリセットされる)。状態は外部の共有ストアに置きます。
// レート制限:状態はプロセス外(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 を検証して二段で守ります。
// 状態変更リクエストは 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は注入経路になります。
// 脆弱:ユーザー入力を .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にサーバーが到達してしまう
// 脆弱:汚染入力がそのまま 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のサーバー環境では、内部メタデータや他サービスへの到達が致命傷になります。
オープンリダイレクト:戻り先をそのまま信じる
// 脆弱:next をそのまま信じてフィッシングへ誘導される
const next = new URL(req.url).searchParams.get("next") ?? "/";
redirect(next); // next="https://evil.example/login" でも飛ぶ
// 修正:相対パスだけ許可する("//" 始まりはプロトコル相対なので除外)
redirect(next.startsWith("/") && !next.startsWith("//") ? next : "/");
パストラバーサル:../ でファイルツリーを抜ける
ユーザー入力をパスに連結してファイルを読むと、../../ で想定ディレクトリの外(.env やキーファイル)に到達されます。
// 脆弱:汚染入力をそのままパスに連結(パストラバーサル)
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)を例にします。GET /api/invoices/1024 を /api/invoices/1025 に書き換えるだけで他人の請求書が返るなら、それがIDORです。BOLAは初版以来ずっとOWASP API Security Top 10の第1位——最頻出のリスクです。
このリクエストはHTTPとして完全に正常です。認証ヘッダーも正しく、不正な文字列も含まない。WAFから見れば「正規ユーザーの正規リクエスト」です。攻撃になるのは、「1025番がこのユーザーの所有物ではない」というアプリ固有の業務的事実による——そしてWAFはあなたのデータモデルを知りません。IDORの発生機序と修正はIDOR/認可欠陥の検出ガイドで深掘りしています。
5-2. RLSの設計ミス——「張った」と「効いている」は違う
SupabaseはPostgreSQLの行レベルセキュリティ(RLS)でデータ層から認可を強制できます(Supabase: Row Level Security / PostgreSQL: Row Security Policies)。強力ですが、「有効化した」ことと「正しく効いている」ことは別物です。よくある設計ミスを挙げます。
-- アンチパターン集
using ( true ) -- 無条件許可=実質RLSなし
-- WITH CHECK の無い書き込みポリシー -- INSERT/UPDATE で他人の行を作れる
-- SECURITY DEFINER 関数で search_path 未固定 -- 権限昇格の温床
-- anon ロールへの過剰な GRANT -- 公開鍵で書き込めてしまう
これらの体系的な検出はRLS設定ミスの検出と監査に、本番マルチテナントでの堅牢なポリシー設計は本番RLSのマルチテナンシー設計にまとめています。
5-3. テナント分離——「根拠にする値」を間違えると全社漏れる
最も恐ろしいのは、ポリシーが構文的に正しいのに根拠にしている値がユーザー操作可能なケースです。SupabaseのJWTには user_metadata(ユーザー自身が更新できる)と app_metadata(サーバー側だけが書ける)があります。
-- 危険: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 );
この違いはツールの構文チェックでは見抜きにくく、「このシステムでテナントの帰属を決めるのは何か」という設計判断そのものです。テナント越え漏洩の検証手順はマルチテナントのクロステナント漏洩検証に切り出しています。
5-4. service_role はRLSを「飛び越える」
最後に、RLSが完璧でも全部無効化する経路があります。service_role キーはPostgreSQLの BYPASSRLS 権限で動き、RLSを完全に無視します。 サーバー側で「確実に動くから」と service_role を使い、所有権チェック(.eq("user_id", user.id))を書き忘れると、RLSの有無に関係なくIDORが開きます。守りの境界は「RLSがあるか」ではなく、**「service_roleをどこに置き、所有権をどこで強制するか」**に移ります。
// 脆弱: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でもヘッダーでも防げず、ドメイン知識を持つ人間しか定義できません。
// 例:クライアントから送られた価格・数量をそのまま信じる
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 はこれを実装しており、インストール不要で走ります。
# インストール不要・設定不要でスキャン(汚染入力→危険シンクを可視化)
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が返れば、実行時に確定です。
# 自分のアプリへの安全・非破壊なプローブ(所有権の食い違いを実行時に確認)
npx @aegiskit/cli probe http://localhost:3000 --correlate
静的の「疑い」と動的の「再現」が一致したものは confirmed-exploitable として最優先で直す——これがSAST↔DASTの相関です。退行を防ぐなら pgTAPでRLSの回帰テストを書き、CIで「他人の行が見えないこと」を継続的に証明します。
-- 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(無料OSS、npx @aegiskit/cli scan)で現状を可視化するのが、最もコスパの良い第一歩です。
一方、垂直リスク(第5節)の「検出・警告」までは自動化できても、「修正=設計」は人間の領域です。 認可の正しさ、テナント帰属の根拠、業務ロジックの妥当性は、あなたのデータモデルと事業ルールを理解した人間にしか判断できません。ここでツールが「安全だ」と言い切る製品は、むしろ危険です。Aegisは水平統制の実装を助け、垂直リスクを検出・警告しますが、認可が正しいことは証明しません。完全に安全にする魔法はありません。
だからこそ、線引きが要ります。どこまで自分で固め、どこから専門家のレビューが要るか——その判断基準はセキュリティ監査が必要になる範囲に整理しました。垂直リスクの設計修正や、既存アプリの認可・RLSレビューが必要なら、セキュリティ監査で承ります。私自身、木材流通業界の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)
- OWASP API1:2023 — Broken Object Level Authorization(BOLA/IDOR、API最頻出リスク)
- OWASP Application Security Verification Standard(ASVS)
- NVD — CVE-2025-48757(不十分なRLSによる未認証アクセス、CWE-863、CVSS 9.3)
- Veracode — 2025 GenAI Code Security Report(AI生成コードの45%にセキュリティ欠陥)
- Supabase Docs — Row Level Security(service_roleはRLSをバイパスする)
- Supabase Docs — API keys(anon と service_role の違い)
- PostgreSQL — Row Security Policies