この記事はこんな人におすすめ
- ログイン機能を実装したあと、APIで何を確認すればよいか迷っている
- 「本人のデータだけ取得する」をコードやテストで表したい
- AIが生成したAPIのコードを、動作確認だけで終わらせたくない
この記事で得られること
認証済みの利用者が他人のデータへアクセスできてしまう原因を整理します。個人用ノートの取得処理を例に、認可のルール、検索条件、テストの期待結果をつなげて考えます。
はじめに
インプレスの公開企画をきっかけに、『Pythonで学ぶ Webアプリのセキュアコーディング』の認証・認可に関する説明を読みました。そこで扱われている「誰かを確認すること」と「何を許可するかを決めること」の違いを、APIの実装まで掘り下げます。
この記事のコードは書籍のサンプルや業務コードとは独立したTypeScriptの学習用例です。書籍全体の要約ではなく、OWASPの資料も参照しながら、一つの設計上の判断を整理してみました。
ログイン確認だけでは、データの閲覧範囲は決まらない
認証では利用者が誰かを確認し、認可ではその利用者にどの操作を許可するかを判断します。
今回は「非公開ノートは、作成した本人だけが読める」というルールにします。共有機能や管理者の例外は設けません。
| リクエストする人 | 読もうとするノート | 期待する結果 |
|---|---|---|
| 利用者A | Aのノート | 内容を返す |
| 利用者A | Bのノート | 内容を返さない |
| 利用者B | Bのノート | 内容を返す |
| 未ログイン | Aのノート | 認証を求める |
Aがログインできたという事実だけでは、Bのノートを読む権限まで得たことにはなりません。
例えば、次のようなAPIがあるとします。
GET /api/notes/note-demo-b
サーバーがログイン確認のあとに、指定されたIDだけでノートを取得して返していたらどうなるでしょうか。AがBのノートIDを知っていれば、Bの内容を取得できてしまいます。
こうした、識別子を通じて権限のないデータにアクセスできる問題は、IDORと呼ばれます。OWASP API Security Top 10のBOLAも、対象データに対する認可の不備を扱っています。
UUIDやボタンの非表示だけでは防げない
ランダムなUUIDはIDの推測を難しくできます。ただし、URLなどからIDを知った利用者を拒否できるかは別の問題です。今回はBのIDを知っているAでも、Bのノートを読めないことを確認します。
また、画面にBのノートへのリンクを出さなくても、APIへのリクエスト自体を防いだことにはなりません。ボタンの表示制御は操作を分かりやすくするために使い、権限の判定はサーバー側で行います。
「誰のデータか」を検索条件に含める
このルールなら、検索条件は次の組み合わせで表せます。
ノートID = リクエストで指定されたID
かつ
所有者ID = サーバーが認証した利用者のID
「どのノートを読みたいか」はリクエストから受け取ります。一方、「誰が読もうとしているか」はサーバー側で検証したセッションやトークンから確定します。リクエスト本文のuserIdを、そのまま本人の証明として使う設計にはしません。
次のコードでは、認証処理で確定した利用者をprincipalとして受け取ります。ノートの検索時点で、所有者の条件も指定します。
export type Principal = Readonly<{ userId: string }>;
export type Note = Readonly<{
id: string;
ownerId: string;
title: string;
}>;
export interface NoteRepository {
findOwnedById(id: string, ownerId: string): Promise<Note | undefined>;
}
export type ReadNoteResult =
| { status: 200; body: { id: string; title: string } }
| { status: 401; body: { error: "unauthenticated" } }
| { status: 404; body: { error: "not_found" } };
// principalは、サーバー側の認証処理で確定した値を渡す。
export async function readNote(
principal: Principal | null,
noteId: string,
repository: NoteRepository,
): Promise<ReadNoteResult> {
if (principal === null) {
return { status: 401, body: { error: "unauthenticated" } };
}
const note = await repository.findOwnedById(noteId, principal.userId);
if (!note) {
return { status: 404, body: { error: "not_found" } };
}
return { status: 200, body: { id: note.id, title: note.title } };
}
これはHTTPサーバー全体のコードではありません。戻り値をHTTPレスポンスに変換する処理、認証情報の検証、URLパラメーターの実行時バリデーションは省略しています。TypeScriptでPrincipal型を付けるだけでは、値が本当に認証済みであることを保証できません。
findOwnedByIdの名前や型だけでも、所有者による絞り込みは保証されません。DBを使う実装では、IDと所有者の両方を検索条件に含め、実際の検索結果まで検証する必要があります。
403と404は、何を知らせるかで選ぶ
この例では、他人のノートと存在しないノートのどちらにも、同じ404の結果を返しています。閲覧できない利用者にノートの存在を知らせない方針です。
権限不足を明示してよいサービスなら、403を返す設計もあります。RFC 9110でも、禁止されたリソースの存在を隠すために404を返す選択が認められています。どちらか一方を常に正解とせず、存在自体を公開してよいかで決めます。
なお、同じステータスと本文にしても、応答時間などを通じた存在の推測まで防いだとは言えません。また、DBの障害は「見つからない」とは別です。この例では検索時の例外をそのまま呼び出し元へ伝え、HTTP層での障害処理を別途設計する前提です。
テストでは「本人が読める」と「他人は読めない」を両方確認する
本人の取得だけを確認しても、認可が働いているかは分かりません。拒否だけを確認すると、誰にもデータを返さない実装でも通ってしまいます。
今回はテスト用のメモリ内データを使い、先ほどの表を検証します。BのノートIDはテストに明示し、推測の難しさに頼らず拒否できるかを確かめます。
先ほどの関数をauthorization.ts、次のコードをauthorization.test.mjsとして同じフォルダへ保存すると、この拒否ケースを実行できます。
import test from "node:test";
import assert from "node:assert/strict";
import { readNote } from "./authorization.ts";
function fixture() {
const notes = [
{ id: "note-demo-a", ownerId: "user-demo-a", title: "Aのノート" },
{ id: "note-demo-b", ownerId: "user-demo-b", title: "Bのノート" },
];
return {
repository: {
async findOwnedById(id, ownerId) {
return notes.find(note => note.id === id && note.ownerId === ownerId);
},
},
};
}
test("AがBの既知のIDを指定してもBのノートを返さない", async () => {
const { repository } = fixture();
assert.deepEqual(
await readNote({ userId: "user-demo-a" }, "note-demo-b", repository),
{ status: 404, body: { error: "not_found" } },
);
});
確認した実行環境はNode.js v26.13.0です。TypeScriptの型除去が有効な環境で、次のコマンドを実行します。Node.jsでの実行は型チェックを行わないため、型チェックは別途必要です。
node --test authorization.test.mjs
検証では、上のケースに加えて、本人による取得、未ログイン時の拒否、検索条件、検索障害の伝播も確認しました。
ただし、ここで確認できるのは関数とメモリ内の検索処理です。実際の認証ミドルウェアやDBへの接続は検証していません。アプリへ組み込んだら、AとBで別々にログインし、実際のAPIを通す統合テストが必要です。
取得APIの検証だけで、更新・削除・一覧・エクスポートまで安全だとは言えません。例えば、単件取得で拒否していても、一覧APIで他人のノートを返していれば情報は漏れます。保護したい操作ごとに対象範囲を確認します。
AIに実装を頼む前に、人間が決めること
「安全なノートAPIを作って」だけでは、誰にどこまで許可するかが曖昧です。今回なら、実装前に次の条件を渡せます。
非公開ノートは作成者本人だけが閲覧できる。
共有と管理者の例外は設けない。
利用者のIDはサーバー側の認証結果から取得する。
他人のノートと存在しないノートは、同じ404の結果にする。
本人の取得成功と、別の利用者からの取得拒否をテストする。
AIが生成したコードを見るときも、この条件から確認します。「ログイン確認がある」「テストが通った」という報告だけでは、対象データへの権限まで検証されたか判断できません。
後から共有機能を追加すれば、本人以外にも読める条件が必要になります。検索条件もテストもそのルールに合わせて見直します。先に所有者チェックを固定し、仕様の変化を無理に当てはめないことも設計上の判断です。
まとめ
認証が成功しても、すべてのデータを扱ってよいことにはなりません。今回は「本人の非公開ノートだけ読める」というルールを、検索条件と成功・拒否のテストに落とし込みました。
AIを使って実装するときも、誰に何を許可し、何を拒否するかは先に決める必要があります。その条件と、テストで実際に確認した範囲を結び付けることが、認可の実装をレビューする手がかりになります。




