第8回 認証・認可のレビュー — AIに任せると最も壊れやすい場所
OWASP Top 10:2025 でも1位を維持する Broken Access Control を、AI生成コードの文脈で扱います。強制点の位置、IDOR、多階層の認可漏れ、権限昇格、返しすぎ、設定不足時の挙動という順にレビュー観点を並べ、RLS を認可の実装に使う場合の確認項目と、AIにレビューとテストを書かせるための指示の出し方まで整理します。
この回のキーメッセージ
認証は定型なのでAIが得意ですが、認可はそのアプリ固有の業務ルールなので、指示しない限り正しく書かれません。レビューで見るのは「実装されているか」ではなく「他人のデータで失敗するか」です。
OWASP Top 10 は2021年版以来のメジャー改訂が入り、2025年版でも A01 は Broken Access Control のままです。順位が動いた項目が多い中で、1位だけが変わっていません。認可の欠陥は新しい攻撃技術ではなく、実装のたびに同じ形で入り込み続けているということです。
AI にコードを書かせると、この傾向がさらに強く出ます。ログイン処理・パスワードのハッシュ化・セッション発行といった認証まわりは、フレームワークごとに正解がほぼ1つに決まっている定型作業です。AI はこれを高い精度で書きます。一方の認可は、「このアプリでは、誰が、どのデータに、どの操作をしてよいか」という業務ルールそのものです。プロンプトに書かれていない業務ルールを AI が推測して実装することはありません。指示がなければ、素直に「ログインしていれば通す」コードが出てきます。
やっかいなのは、この状態でもテストが通ることです。AI に「テストも書いて」と頼むと、たいてい「ログインした自分のデータが取得できること」を確認するテストが生成されます。認可が完全に抜けていても、このテストは緑になります。テストが通ることと認可が正しいことのあいだには、何の関係もありません。
第7回で扱ったレビュー観点の全体像(AI生成コードのレビュー観点)のうち、この回はいちばん壊れやすい1項目だけを深く見ます。
1. 強制点が信頼できる境界にあるか
最初に見るのは、認可の判定がどこに書かれているかです。次の3つは、いずれも認可ではありません。
| 実装 | 何をしているか | なぜ認可にならないか |
|---|---|---|
管理メニューを isAdmin で非表示にする | 見た目の出し分け | API を直接叩けば通る |
| クライアント側で role を見て分岐する | UI の状態管理 | ブラウザ上の値はいくらでも書き換わる |
| フロントエンドのルーティングでガードする | 画面遷移の制御 | 画面を経由しないリクエストには効かない |
これらをやってはいけないという話ではありません。UI の出し分けは必要です。問題は、それだけで守った気になることです。UI の出し分けは利便性の機能であり、セキュリティ境界はサーバー側にしかありません。
強制されるべき場所は、API ルート・Service 層・Repository 層のいずれか、つまりリクエストを受けたあとに必ず通る信頼できる境界です。レビューでは「この操作を、画面を経由せず HTTP リクエストだけで実行したらどうなるか」を頭の中で1回実行します。答えが「通ってしまう」なら、そこが直す場所です。
壊れているコードの兆候は分かりやすく、.tsx や .jsx にだけ権限判定があり、対応する API ルートには session の有無しか見ていない、という形で現れます。
// 危ない: ログインしているかどうかしか見ていない
export async function POST(req: Request) {
const session = await getSession(req);
if (!session) return new Response("Unauthorized", { status: 401 });
const { invoiceId, amount } = await parseBody(req);
return Response.json(await updateInvoice(invoiceId, amount));
}
getSession が通った時点で認証は済んでいますが、この請求書がこのユーザーのものかは誰も確認していません。認証(誰か)と認可(何をしてよいか)を、コード上でも別の行として分けて書くと、抜けが目に見えるようになります。
2. IDOR — 「取得してから確認」ではなく「絞って取得」
URL やリクエストボディに含まれる ID を、他人のものに差し替えたら通ってしまう。これが IDOR(Insecure Direct Object Reference)で、Broken Access Control のうち最も頻度が高い形です。AI 生成コードでは、リソース ID をそのまま主キー検索に渡す実装が既定で出てきます。
対処として一見正しく見えるのが、取得したあとに所有者を確認する形です。
// 惜しい: 順序に依存していて、コピーされるたびに抜ける
const invoice = await db.invoice.findUnique({ where: { id: invoiceId } });
if (invoice.userId !== session.userId) throw new ForbiddenError("...");
この形は動きますが壊れやすい実装です。理由は3つあります。1つ目は、チェックを書き忘れた1箇所だけが穴になること。2つ目は、findUnique の結果をログやエラーメッセージに出すと、チェック前に他人のデータが外へ出ること。3つ目は、この関数がコピーされて別の導線に流用されたとき、下の2行が一緒に運ばれる保証がないことです。
安全な形は、所有者を検索条件に含めて取得することです。
// 安全: 他人の請求書は「存在しない」として返ってくる
const invoice = await db.invoice.findFirst({
where: { id: invoiceId, userId: session.userId },
});
if (!invoice) throw new NotFoundError("invoice not found");
こう書くと、認可が検索条件の一部になるため、書き忘れると機能そのものが動きません。壊れたときに動かなくなる書き方を選ぶ、というのが認可の設計原則です。
なお、他人のリソースに対して403を返すか404を返すかは、「その ID が存在すること自体を伏せたいか」で決めます。伏せたい場合は404に揃えます。エラーメッセージに所有者名やテナント名を含めないことも合わせて確認します。
3. 認可の抜けは周辺導線に出る
AI は依頼された主要導線を実装します。「一覧と詳細を作って」と頼めば一覧と詳細に認可が入り、あとから足した更新・削除・エクスポート・検索には入らないことがあります。レビューでは、同じリソースに触るすべての入口を並べて、認可の有無を表にします。
| 操作 | 典型的な抜け方 |
|---|---|
| 一覧 | 認可がかかっている(依頼された導線のため) |
| 詳細 | 認可がかかっている |
| 更新・削除 | ID を受け取るだけで所有者を絞っていない |
| 一括操作 | ID の配列を受け取り、配列の中身を検証していない |
| CSV / PDF エクスポート | 一覧とは別のクエリで実装され、絞り込みが再現されていない |
| 検索・オートコンプリート | 全文検索インデックスを直接引き、テナント条件が抜ける |
| 集計・ダッシュボード | 集計クエリなので個別データではないと判断され、素通しになる |
エクスポートと検索は特に危険です。どちらも「読み取りだけだから」という理由で軽く扱われますが、実際には一覧よりも多くの行を一度に返します。集計値も、条件を細かく絞れば個別のレコードを推定できるため、認可の対象から外す理由にはなりません。
一括操作については、配列で受け取った ID の一部だけ他人のものが混ざっているケースを必ず確認します。安全な形は、ID の配列と所有者条件の両方で絞って取得し、取得できた件数が要求件数と一致しない場合は全体を失敗させることです。
4. セッションとトークン
認証まわりは AI が得意な領域ですが、既定値のまま出てくるため、運用上の要件と合っているかは人が確認します。
- 有効期限: アクセストークンの寿命が長すぎないか。長寿命トークンは、漏れたときに失効させる手段がないと回収不能になります。
- 失効の手段: ログアウト・パスワード変更・退職処理で、既存のセッションを実際に無効化できるか。ステートレスな JWT だけで組むと、期限が切れるまで有効なままです。管理画面や決済に関わるアプリでは、サーバー側にセッションの実体を持つ設計を検討します。
- 保存場所: ブラウザに置くなら Cookie に
HttpOnly・Secure・SameSiteを付けているか。localStorageに置く実装が生成されたら、XSS が1つあるだけでトークンが読み出せる状態だと判断します。 - リフレッシュ: リフレッシュトークンがローテーションされるか、再利用を検知したときにセッション系列全体を失効させるか。
Cookie の属性はレビューで機械的に確認できます。設定箇所を grep して、次の3つが揃っているかを見ます。
// 確認する属性
cookies().set("session", token, {
httpOnly: true,
secure: true,
sameSite: "lax", // 認証系は "strict" も検討する
path: "/",
maxAge: 60 * 60 * 8,
});
5. 権限昇格の経路
「ユーザーが自分自身の権限を書き換えられるか」は、単独で確認する価値のある観点です。AI は「プロフィール更新 API」を素直に実装するので、リクエストボディをそのままユーザーレコードへマージするコードが出やすくなります。
// 危ない: role や plan もボディに含めれば書き換わる
await db.user.update({ where: { id: session.userId }, data: body });
更新を許すフィールドは、許可リストで明示するのが原則です。「これは更新してはいけない」という除外リストは、フィールドが増えるたびに漏れます。
Supabase を使っている場合、この観点はさらに具体的な形になります。JWT の user_metadata はユーザー自身が書き換えられるため、ここに管理者フラグを置くと誰でも管理者になれます。認可に使ってよいのはユーザーが変更できない app_metadata か、データベース側のテーブルです(詳細は RLS を理解する を参照してください)。
狙われやすいのは、通常のログイン以外でアカウントの状態が変わるフローです。
| フロー | 確認すること |
|---|---|
| 招待 | 招待する側が、自分より強い権限を付与できないか。招待リンクに有効期限と使い切りがあるか |
| パスワードリセット | トークンが単発・短命か。リセット完了時に既存セッションを失効させているか |
| メールアドレス変更 | 変更先の確認が済むまで旧アドレスで有効か。変更が「同じメールドメインなら自動承認」のような判定に使われていないか |
| 組織・テナント参加 | 参加リクエストの承認者が、そのテナントの管理者であることを確認しているか |
6. 返しすぎと、設定不足時の挙動
認可が通ったあとにも、2つ見る場所があります。
返しすぎは、DB 行や内部オブジェクトをそのままレスポンスにしている状態です。select * で取った行を Response.json(row) に渡すと、パスワードハッシュ・内部メモ・他システムの ID・論理削除フラグまで出ます。フロントエンドが表示に使わないだけで、レスポンスには載っています。必要なフィールドだけを選ぶか、公開用の型へ明示的に詰め替えます。この詰め替え関数を1箇所に置いておくと、カラムが増えたときに自動で漏れることがなくなります。
設定不足時の挙動は、環境変数が無いときに認証を素通りさせていないかです。AI が生成する初期化コードには、ローカル開発を楽にするためのフォールバックが混ざることがあります。
// 危ない: 本番で環境変数が抜けた瞬間に認証が消える
if (!process.env.AUTH_SECRET) {
console.warn("AUTH_SECRET not set, skipping auth");
return next();
}
正しい挙動は fail closed、つまり設定が足りなければ起動を止めるか、リクエストを拒否することです。起動時に必要な環境変数を検証して、欠けていれば例外を投げる形にします。「開発中は緩く」が必要なら、環境名で明示的に分岐させ、本番の分岐では必ず失敗させます。
7. RLS を認可の実装として使うときのレビュー観点
Supabase のように Row Level Security で認可を実装する場合、認可はアプリケーションコードではなくデータベースにあります。レビューの対象も移ります。ポリシーの書き方そのものは RLS を理解する、設計パターンは Supabase の RLS 深掘り にあるので、ここではレビューで見る点だけを挙げます。
有効化漏れを機械的に検知する。 ポリシーを書いていないテーブルは、Supabase の構成では実質公開になります。テーブルを1つ追加したときに RLS の有効化を忘れる、という抜け方が最も多いため、目視ではなく仕組みで検知します。ダッシュボードの Security Advisor に該当項目があるほか、pg_tables の rowsecurity を引くクエリを CI で流し、公開スキーマに RLS 無効のテーブルがあれば落とす形にできます。
-- public スキーマで RLS が無効なテーブルを列挙する
select schemaname, tablename
from pg_tables
where schemaname = 'public'
and rowsecurity = false;
auth.uid() / auth.jwt() だけに頼らない。 公式は、anon ロールを除外する唯一の手段としてこれらの関数のみに頼らないよう明示しています。to authenticated のようにロールを明示して、対象外のロールをポリシー評価の前に外します。
ビューは RLS を迂回しうる。 ビューは既定で作成者の権限で動くため、ベーステーブルの RLS が効きません。Postgres 15以降は with (security_invoker = true) を付けて呼び出し元の権限で評価させます。管理画面用の集計ビューを AI に作らせたときは、必ずここを見ます。
性能も認可の一部として見る。 これは運用の話に見えますが、認可の観点です。ポリシーが遅いと、大きなテーブルでタイムアウトが発生し、最終的に「RLS を外す」という運用判断につながります。防ぐための手当ては2つあります。
-- auth.uid() を select でラップし、initPlan としてキャッシュさせる
create policy "own rows only"
on public.invoices for select to authenticated
using ( user_id = (select auth.uid()) );
-- ポリシーがフィルタする列にインデックスを張る
create index invoices_user_id_idx on public.invoices (user_id);
そのうえで、RLS を有効にした状態のクエリを EXPLAIN ANALYZE で確認します。開発時にダッシュボードの Table Editor で確認すると、強い権限で動くためポリシーの効果も性能特性も見えません。確認は必ずアプリ側の鍵で行います。
8. AIにこの観点でレビューさせる
第7回で用意したレビュー用テンプレートに、この回の観点を差し込む形で使います。「セキュリティをレビューして」と頼むと一般論が返ってくるので、見る場所と出力形式を指定します。
以下の差分について、認可(Broken Access Control)だけをレビューしてください。
他の観点は挙げないでください。
確認する順序:
1. 認可の強制点が API / Service / Repository のどこにあるか。
UI・クライアント側・フロントエンドのルーティングにしか無いものは指摘する。
2. リソース ID を受け取る処理で、所有者を検索条件に含めて取得しているか。
「取得してから所有者を確認する」形は指摘する。
3. 同じリソースに触る入口(一覧・詳細・作成・更新・削除・一括操作・
エクスポート・検索・集計)を列挙し、認可がかかっていないものを挙げる。
4. ユーザー自身が role / plan / 所属テナントを書き換えられる経路があるか。
更新可能フィールドが許可リストで明示されているか。
5. レスポンスが DB 行や内部オブジェクトをそのまま返していないか。
6. 環境変数や設定が欠けたときに、認証・認可が素通りする分岐が無いか。
出力形式: 各指摘について
- 該当ファイルと行
- 攻撃者が何をすると何が起きるか(1文)
- 修正後のコード
根拠が差分の中に無い推測は書かず、「差分だけでは判断できない」と書いてください。
最後の1行を入れておくと、確認できない部分を埋めるために断定的な指摘を作ることが減ります。返ってきた指摘は、そのまま採用せず、上の1〜6の観点で人が読み直します。AI にレビューさせる運用そのものの設計は AIコードレビューの運用設計 にまとめてあります。
9. 「他人のデータで失敗する」テストを書かせる
認可のテストは、放っておくと書かれません。自分のデータで成功することを確認するテストは自然に生成されますが、他人のデータで失敗することを確認するテストは、明示的に依頼しない限り出てきません。指示の形を固定しておきます。
このエンドポイントの認可テストを追加してください。
正常系は既存のもので足りているので、次の失敗系だけを書いてください。
前提: ユーザー A とユーザー B を作り、リソースは A のものとして作成する。
1. B のセッションで、A のリソースを GET すると 404 が返ること
2. B のセッションで、A のリソースを PATCH / DELETE すると 404 が返ること
3. 未認証で同じリクエストを送ると 401 が返ること
4. B のセッションで一覧を取得したとき、A のリソースが 1 件も含まれないこと
5. B が自分のプロフィール更新 API に role を含めて送っても、role が変わらないこと
各テストは「拒否されたこと」だけでなく、
レスポンスボディに A のデータが 1 バイトも含まれないことも確認してください。
5番目のように、権限昇格のテストを1本入れておくと、あとからフィールドが増えたときに気付けます。最後の1行は返しすぎの検知を兼ねています。
このテストは、認可を実装した直後よりも、機能を追加したときに効きます。新しいエンドポイントを足すたびに同じ5本を書く運用にしておくと、AI に実装を任せても認可の抜けが CI で止まります。
まとめ
- 認証は定型なので AI が正しく書きますが、認可はアプリ固有の業務ルールなので、指示しない限り実装されません。順位が動き続ける OWASP Top 10 の中で、A01 Broken Access Control だけが1位のままです。
- 「取得してから所有者を確認する」ではなく「所有者で絞って取得する」。壊れたときに機能が動かなくなる書き方を選びます。
- 認可の抜けは、一覧や詳細ではなくエクスポート・検索・一括操作・集計に出ます。同じリソースに触る入口を並べて確認します。
- RLS で認可を実装する場合、有効化漏れは目視ではなく CI のクエリで検知し、ビューの
security_invokerと、ポリシー列のインデックスまでを認可の範囲として見ます。 - AI にレビューさせるときも、テストを書かせるときも、「他人のデータで失敗すること」を明示的に指定します。書かれていない要件は実装もテストもされません。
参考リンク
- OWASP Top 10:2025 Introduction — A01 Broken Access Control を含む現行版の全体像
- Supabase: RLS performance and best practices —
auth.uid()のラップ、インデックス、EXPLAIN ANALYZEでの確認 - RLS を理解する|書かないと何が公開されるか — ポリシーの書き方とビュー迂回の実例
- Supabase の RLS 深掘り — 設計パターンと性能面の詳細
- 管理画面を守る|Deployment Protection の各方式と限界 — デプロイ URL の保護とアプリ認証の違い
- AI生成コードのレビュー観点 — この回の前提になるレビューの全体像