TL;DR
- 認証(Authn)と認可(Authz)を分けずに設計すると、後から直せなくなる
- JWT は万能ではない。無効化できない問題 と向き合う前に使うな
- 認可は RBAC を基本、境界だけ ABAC で補う ハイブリッドが現実解
- やり直しは「監査ログ → 認証統一 → 認可の構造化 → 段階適用」の順で進める
1. 用語整理:Authn と Authz を分ける
認証と認可は混同されがちですが、別の問題を解いています。
| 観点 | 認証 (Authentication / Authn) | 認可 (Authorization / Authz) |
|---|---|---|
| 問い | 誰か? | 何をしてよいか? |
| 入力 | 資格情報(password / token / OIDC ID Token) | 認証済みのprincipal + 操作対象 + 操作種別 |
| 出力 | principal(ユーザー/サービスの識別子) | allow / deny |
| 失敗の代償 | なりすまし | 権限昇格・情報漏洩 |
設計の第一歩はこの分離 です。「JWT を使う」は Authn の話、「管理者だけが消せる」は Authz の話。ひとつのミドルウェアに両方詰めると、認可ロジックが認証トークンの形式に依存して、後から差し替えられなくなります。
関連: AIコーディング導入のセキュリティ設計 / AIガバナンスの権限マトリクス
2. 認証(Authn)の選択:session / JWT / OIDC
2.1 3方式の比較
| 方式 | 状態 | 無効化 | スケール | 典型用途 |
|---|---|---|---|---|
| session | サーバ側に保持 | ✅ 即時(削除するだけ) | DB/Redis依存 | Web アプリ(同一オリジン) |
| JWT | 状態を持たない | ❌ 原則不可(有効期限まで生きる) | 無状態で水平拡張容易 | マイクロサービス内通信・API |
| OIDC | IdP に集約 | ✅ IdP 側で失効可 | IdP に依存 | SaaS のログイン・SSO |
session は古くない。むしろ「ログアウトした瞬間にアクセスを止められる」という特性は、現代の SaaS でも価値が大きい。無状態を求める特別な理由がなければ、Web のログイン = session(or OIDC + session) が安全側です。
2.2 JWT だけでは足りない理由
JWT を採用するときに必ず踏む3つの罠:
- 無効化できない: 「ログアウトしたのにトークンが生きている」問題。refresh token と短命な access token に分け、失効対象を Redis などで管理する追加層が必要
- 共有秘密(HS256)の鍵漏洩: サービスが複数あるときは非対称鍵(RS256/ES256) + JWKS 経由の鍵配布に切り替える
alg:noneと algorithm confusion: 受信側がalgヘッダを信じると検証がスキップされる。とくに古典的な HS/RS confusion(発行側は RS256、受信側が HS256 で「公開鍵を共有秘密として」検証してしまう)は JWT 実装事故の筆頭。受信側で許可する alg を厳格にホワイトリスト する
最小の検証ミドルウェア(Node/TypeScript, jose v5 を利用):
import { jwtVerify, createRemoteJWKSet } from 'jose'
import { getRevocationClient } from './revocation-store' // 後述
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URL!))
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ['RS256'], // alg 固定(HS/RS confusion を封じる)
issuer: process.env.ISSUER!, // iss 検証
audience: process.env.AUDIENCE!, // aud 検証
clockTolerance: '5s', // 許容クロックずれ(秒)
})
const jti = payload.jti as string | undefined
if (!jti) throw new Error('jti missing') // 失効管理できないトークンを拒否
if (await isRevoked(jti)) {
throw new Error('token revoked')
}
return payload
}
algorithms を固定しない JWT 検証はほぼすべて脆弱と思って差し支えありません。
2.2.1 isRevoked の最小実装(Redis ブロックリスト)
access token を短命(5〜15分)にし、ログアウトや侵害時に残余寿命の間だけ Redis に jti を積みます。TTL は access token の残寿命に揃える のがコツで、期限を過ぎれば自然淘汰されます。
// revocation-store.ts
import { Redis } from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
const key = (jti: string) => `revoked:jti:${jti}`
export async function revoke(jti: string, remainingSec: number) {
// 残寿命だけ TTL を設定。期限を超えたら自動削除
await redis.set(key(jti), '1', 'EX', Math.max(remainingSec, 1))
}
export async function isRevoked(jti: string): Promise<boolean> {
return (await redis.exists(key(jti))) === 1
}
運用ポイント:
- ログアウト時: その session 配下の全 jti を取得して
revokeする - 侵害検知時: ユーザー単位で
revoked:user:<userId>を立て、トークン発行時刻を比較する方式(global logout)に拡張 - Redis ダウン時: 認証 API は
fail-closed(トークンを拒否)でもfail-open(通す)でも事故る。専用の availability SLO をかけて監視する
2.2.2 JWT 署名鍵のローテーション(JWKS の kid 運用)
鍵は 90 日以内に自動ローテします。切替は publish → rotate → revoke の3段で、ダウンタイムを作らないのがコツです。
| 段階 | 公開鍵(JWKS) | 署名鍵(現行) | 備考 |
|---|---|---|---|
| 1. Publish | {kid: v2} を追加。v1 も残す | v1 で署名 | 検証側は両方受け入れる |
| 2. Rotate | v1 と v2 を両方公開 | v2 で署名 | 旧 token は v1 で検証継続 |
| 3. Revoke | v1 を JWKS から削除 | v2 のみ | 旧 token の有効期限が切れたのを確認してから |
JWT ヘッダの kid が JWKS のどのキーと一致するかで検証鍵が選ばれるため、必ず kid を付けて発行 します。kid がないと、鍵ローテのたびに全サービスを同時にデプロイしないと事故ります。
2.3 OIDC を使うべきサイン
OIDC は OAuth 2.0 の「認可」フローの上に「認証」の層(id_token = JWT)を載せた仕様 です。OAuth 2.0 単体は「このアプリに〇〇への代理アクセスを許可する」ための認可プロトコルで、厳密には「誰か」を返しません。「ログイン」を実装したいなら OIDC を選ぶ、が基本原則です。
サイン:
- 複数サービスで SSO したい
- MFA / パスワードレスを IdP に任せたい
- 退職者の権限を 一括失効 したい
これらが1つでも当てはまるなら OIDC(Auth0 / Cognito / Keycloak / Azure AD)に寄せるのが運用コスト最小です。自前で OAuth 2.0 / OIDC サーバを書く判断は、要件が極端でなければ避けます。
3. 認可(Authz)の選択:ACL / RBAC / ABAC / ReBAC
| モデル | 表現力 | 実装難度 | 主な利用場面 |
|---|---|---|---|
| ACL | 低 | 低 | 「このファイルは user A だけ読める」 |
| RBAC | 中 | 中 | 「admin / editor / viewer」役割で縛る |
| ABAC | 高 | 高 | 「部署 + 時間帯 + IP レンジ」で動的判定 |
| ReBAC | 高 | 中〜高 | 「この doc の owner が所属する team の member」関係ベース(Google Zanzibar系) |
「どれが正解か」ではなく、要件に合う最小の表現力を選ぶ のが原則です。RBAC で足りるなら ABAC を入れない。入れた瞬間に、ポリシーの記述量とテスト量が一気に増えます。
3.1 RBAC の最小実装
// policy.ts
type Role = 'admin' | 'editor' | 'viewer'
type Action = 'read' | 'write' | 'delete'
const policy: Record<Role, Action[]> = {
admin: ['read', 'write', 'delete'],
editor: ['read', 'write'],
viewer: ['read'],
}
export function can(role: Role, action: Action): boolean {
return policy[role]?.includes(action) ?? false
}
これで「管理者だけが消せる」は表現できますが、「自分が所有するdocだけ編集できる」は表現できません。所有権のような リソース属性が絡む要件が出た時点でRBAC単独は限界です。
3.2 RBAC + ABAC のハイブリッド
現実解は「基本はRBACで静的に縛り、リソース属性が絡む部分だけ ABAC で上書き」です。
type Principal = { id: string; role: Role; dept: string }
type Resource = { id: string; ownerId: string; dept: string }
export function canOn(p: Principal, a: Action, r: Resource): boolean {
if (!can(p.role, a)) return false // 1. RBAC
if (a === 'write' && p.id !== r.ownerId) { // 2. 所有者以外は write 不可
return p.role === 'admin' && p.dept === r.dept // ただし同部署adminは例外
}
return true
}
この書き方の利点は、RBAC の表(静的) と ABAC の例外(動的) が同じファイルで見えること。ポリシーが散らばらないので、監査でもレビューでも追跡できます。
3.3 ReBAC が向く場面
「この document の共同編集者が所属する team の member にも閲覧権」のような 関係の連鎖 を扱うなら ReBAC(Google Zanzibar ベース: SpiceDB / OpenFGA)を検討。Notion / Google Docs 的な共有構造には ReBAC が自然です。
4. 実装レイヤーの責務分割
認可ロジックをどこに置くかで、コードの寿命が変わります。
flowchart LR
A[Request] --> B[Authn Middleware]
B -->|principal| C[Policy Layer]
C -->|allow| D[Handler]
D --> E[Data LayerRow-Level Security]
C -->|deny| F[403]
- Authn Middleware: トークン検証・principal の復元。ここには認可を入れない
- Policy Layer:
canOn(principal, action, resource)だけを担当する独立モジュール - Handler: ビジネスロジック。policy の呼び出しは必ず handler の入口で
- Data Layer: 最後の砦として、DB の Row-Level Security や service account の権限も絞る
policy を独立モジュールにするのが肝。handler に直接 if (user.role === 'admin') と書くと、テストもレビューも辛くなります。
5. 監査と可観測性:誰が何にアクセスしたか
認可を設計しても、ログがなければ壊れているかわからない。最低限、以下を構造化ログに残します。
{
"ts": "2026-04-19T12:00:00Z",
"principal_id": "u_123",
"principal_role": "editor",
"action": "document.write",
"resource_id": "doc_456",
"resource_owner": "u_789",
"decision": "deny",
"reason": "not owner, role=editor"
}
decision: deny のログがゼロならたいてい壊れています。deny が出ることは健全な兆候。監査設計の全体像は AIガバナンスの権限マトリクス を参照。
6. よくある失敗パターン
失敗1: 認証と認可の混同 「JWT が通ったから書き込み OK」という middleware。JWT はあくまで 誰か の証明でしかない。
失敗2: JWT を長命にする 有効期限24時間のaccess token。退職者が丸1日アクセス可能。access token は 5〜15 分、refresh token で再発行が基本。
失敗3: 権限の表現力不足
RBAC で無理やり「所有者のみ編集」を表現しようとして owner_editor_of_doc_123 のような role が量産される。属性が絡んだら ABAC に切り替える判断。
失敗4: 管理画面の特権認可をスキップ
「社内だけだから」と /admin を VPN 信頼だけで守る。SSRF / 踏み台経由で即侵入される。VPN は Authn の補助、Authz の代わりにはならない。
失敗5: 秘密情報ローテ漏れ JWT 署名鍵・API key を1年以上ローテしていない。90日以内の自動ローテを仕組み化する。
失敗6: 認可のテスト不在 「管理者だけが削除できる」を unit test にしていない。認可はネガティブケース(deny されるべき) こそテストする。
7. やり直しの段階移行手順
既存システムを壊さず差し替えるには、追加→切替→撤去 の順が安全です。
- 監査ログの追加: 現状の認可判断をすべてログ化(deny 含む)。撤去なしで足すだけ
- 認証の統一: session / JWT / OIDC を並走している場合、新規は OIDC(or session)に寄せる。既存発行済み tokenは有効期限切れで自然淘汰
- Policy Layer の抽出: handler 中に散らばる
if (role === ...)を policy モジュールに移す。挙動を変えず移動だけ - RBAC 整理: 既存の role 定義を棚卸し、未使用・重複・過剰権限を削除
- ABAC / ReBAC 導入: 必要な箇所だけ属性/関係判定を追加
- 負の権限のテスト整備: 「このrole では deny されるべき」テストを網羅
- 旧経路の撤去: 移行完了後に JWT 自前発行や session 併用を剥がす
いきなり全書き換えは事故ります。1 ステップずつ、deploy して1週間ログを見て、問題なければ次 が現実解です。
8. 判断チェックリスト
着手前にこれを埋めておくと、設計のブレが減ります。
- Authn と Authz は別モジュールか
- JWT を使うなら
alg固定・短命・無効化レイヤがあるか - OIDC に寄せられる要件は OIDC に寄せたか
- RBAC で足りるか、ABAC / ReBAC が本当に必要か
- policy は 独立モジュール か、handler に散らばっていないか
- deny のログが出ているか
- ネガティブケースのテストがあるか
- 秘密情報の自動ローテが仕組み化されているか
まとめ
認証・認可の設計を「分けて・最小で・ログして」進めれば、やり直しは怖くありません。逆にこの3つを怠ると、規模が大きくなるほど書き直し不可能になります。
- 分けて: Authn と Authz を別レイヤーに
- 最小で: 要件に合う最小の表現力を選ぶ(RBAC → ABAC → ReBAC の順で検討)
- ログして: allow/deny を構造化ログに残し、監査可能にする
関連: Claude Code hooks の実践パターン集 / MCP権限設計の判断軸 / Edge Functions採用の判断軸とアンチパターン
AI Agent / MCP の認可境界(2026-05 追記)
AI コーディングの普及で「人間 + Agent + Tool」3者間の認可境界が新たな設計課題になっています。本記事の Authn/Authz 分離・RBAC/ABAC/ReBAC の選定軸は、AI Agent の transient な権限にもそのまま適用できます(経験則)。
4 つの認可境界モデル
| 境界 | 例 | 推奨パターン |
|---|---|---|
| user × agent | 人間が agent を起動する権限 | OIDC + delegation token |
| agent × tool | agent が MCP server tool を呼ぶ権限 | MCP scope + capability token[公式値] |
| agent × agent | 親 agent が child sub-agent を呼ぶ | path-based scope(例: Codex /root/agent_a)[公式値] |
| agent × data | agent が DB / API を読み書きする権限 | RBAC + ABAC(既存設計の延長) |
MCP scope 設計との接続
詳細な scope 粒度は MCP権限設計の判断軸 allowlist粒度とscope命名から組織導入まで を参照[公式値](MCP 仕様)。MCP server 実装側の認証は OAuth 2.1 / Dynamic Client Registration / Client ID Metadata Documents の優先順で[公式値](MCPサーバー設計パターン集 参照)。
公式仕様の最新確認(2026-05)
- OAuth 2.1: PKCE 必須、implicit flow 廃止、Refresh token rotation 推奨[公式値](OAuth 2.1 draft)
- OWASP ASVS 5.0: 認証・認可の検証要件が更新されている[公式値](OWASP ASVS)
- OWASP LLM Top 10 (2025-26 版): LLM02 Sensitive Information Disclosure / LLM06 Excessive Agency が認可境界に直結[公式値](OWASP LLM Top 10)
詳細な agent 認可境界の設計パターンは別記事 AI Agent 認可境界の設計パターン集(次回追加予定 / queue 079)で 4 モデルを掘り下げます。
FAQ
Q1. JWT を長命にしてはいけない理由は?
revocation(取り消し)が困難だからです(経験則)。JWT は署名のみで検証されるため、流出した token を即時無効化できません。短命(5-15 分)にして、refresh token rotation で運用するのが現実解。OAuth 2.1 でも同方針が推奨[公式値]。
Q2. RBAC と ABAC をどう使い分けますか?
まず RBAC で粗く分け、ABAC で属性条件を足すのが現実解(経験則)。RBAC のロール数が 50+ になると管理不能になるため、その時点で ABAC(部署 / 担当顧客 / 時間帯)を組み合わせる方針が定着。詳細は本記事 §3 を参照。
Q3. AI Agent に渡す権限はどう絞りますか?
Capability token + MCP scope 細粒度で絞るのが現実解(経験則)。1 つの agent タスクに必要な最小権限だけを transient な token で渡し、タスク完了で失効させます。詳細は MCP権限設計の判断軸 と PlanGate v8.6 Hook Enforcement。
Q4. ReBAC(Relationship-Based)はどんな時に必要ですか?
**「誰がどのリソースとどんな関係にあるか」**で許可を決めたい時です(経験則)。Google Zanzibar / SpiceDB / OpenFGA が有名で、SaaS の owner / collaborator / viewer など、組織横断のグラフ型権限に強いです[公式値](OpenFGA 公式)。
Q5. 認可ログで最低限取るべき項目は?
5 項目(経験則): (1) actor(user / agent ID)、(2) action、(3) resource、(4) decision(allow / deny)、(5) reason(policy ID + matched rule)。詳細は AIエージェントの可観測性と障害解析 と AIコーディング運用の可観測性スタック2026 を参照。
References
- OAuth 2.1 draft — PKCE 必須・implicit 廃止・refresh rotation
- OWASP ASVS 5.0 — 認証・認可の検証要件
- OWASP LLM Top 10 (2025-26) — LLM06 Excessive Agency 等
- MCP 仕様 — Model Context Protocol の認証/認可
- OpenID Connect Core 1.0 — OIDC 公式仕様
- OpenFGA 公式 — オープンソース ReBAC 実装
- Google Zanzibar Paper — ReBAC の原典
