RFC 9068 - OAuth 2.0アクセストークンのJWTプロファイル
- ステータス: Proposed Standard
- 発行日: October 2021
- ストリーム: IETF
- エラッタ: エラッタなし
概要 (Abstract)
本仕様は、OAuth 2.0アクセストークン (Access Token) をJSON Web Token (JWT) 形式で発行するためのプロファイル (Profile) を定義します。異なるベンダーの認可サーバー (Authorization Server) とリソースサーバー (Resource Server) は、このプロファイルを活用して、相互運用可能な方法でアクセストークンを発行および使用できます。
本メモのステータス (Status of This Memo)
これはインターネット標準化過程文書 (Internet Standards Track Document) です。
本文書はインターネット技術タスクフォース (IETF) の成果物です。これはIETFコミュニティのコンセンサスを表しています。公開レビューを受け、インターネット技術運営委員会 (IESG) によって発行が承認されました。インターネット標準に関する詳細情報は、RFC 7841のセクション2で入手できます。
本文書の現在のステータス、正誤表、およびフィードバックの提供方法に関する情報は、https://www.rfc-editor.org/info/rfc9068 で入手できます。
目次 (Contents)
- 1. Introduction (はじめに)
- 1.1 Requirements Notation and Conventions (要求事項の表記と規約)
- 1.2 Terminology (用語)
- 2. JWT Access Token Header and Data Structure (JWTアクセストークンのヘッダーとデータ構造)
- 2.1 Header (ヘッダー)
- 2.2 Data Structure (データ構造)
- 2.2.1 Authentication Information Claims (認証情報クレーム)
- 2.2.2 Identity Claims (識別情報クレーム)
- 2.2.3 Authorization Claims (認可クレーム)
- 2.2.3.1 Claims for Authorization Outside of Delegation Scenarios (委譲シナリオ外の認可クレーム)
- 3. Requesting a JWT Access Token (JWTアクセストークンの要求)
- 4. Validating JWT Access Tokens (JWTアクセストークンの検証)
- 5. Security Considerations (セキュリティに関する考慮事項)
- 6. Privacy Considerations (プライバシーに関する考慮事項)
- 7. IANA Considerations (IANAに関する考慮事項)
- 7.1 Media Type Registration (メディアタイプの登録)
- 7.1.1 Registry Content (レジストリ内容)
- 7.2 Claims Registration (クレームの登録)
- 7.2.1 Registry Content (レジストリ内容)
- 7.2.1.1 Roles (ロール)
- 7.2.1.2 Groups (グループ)
- 7.2.1.3 Entitlements (権限)
- 7.2.1 Registry Content (レジストリ内容)
- 7.1 Media Type Registration (メディアタイプの登録)
- 8. References (参考文献)
- 8.1 Normative References (規範的参照)
- 8.2 Informative References (参考情報)
- Acknowledgements (謝辞)
- Author's Address (著者の連絡先)
著作権表示 (Copyright Notice)
Copyright (c) 2021 IETF Trustおよび文書の著者として特定された人物。全著作権所有。
本文書は、BCP 78およびIETF文書に関するIETF Trustの法的規定 (https://trustee.ietf.org/license-info) の対象となります。これらの規定は本文書の発行日に有効です。本文書に関するあなたの権利と制限を説明しているため、これらの文書を注意深く確認してください。本文書から抽出されたコードコンポーネントには、Trust法的規定のセクション4.eに記載されている簡易BSDライセンステキストを含める必要があり、簡易BSDライセンスに記載されているように保証なしで提供されます。
関連リソース
- 公式原文: RFC 9068
- 公式ページ: RFC 9068 DataTracker
- 正誤表: RFC Editor Errata
RFC 9068 - JWT Profile for OAuth 2.0 Access Tokens
基本情報
- RFC番号: 9068
- タイトル: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens
- 日本語タイトル: OAuth 2.0アクセストークンのJWTプロファイル
- 公開日: 2021年10月
- ステータス: PROPOSED STANDARD (提案標準)
- 著者: V. Bertocci (Auth0), B. Campbell (Ping Identity)
概要 (Abstract)
この仕様は、OAuth 2.0アクセストークンをJWT形式で使用するための標準プロファイルを定義しています。JWTアクセストークンの必須クレーム、推奨クレーム、および検証要件を規定し、リソースサーバーが毎回イントロスペクションエンドポイントを呼び出すことなく、アクセストークンを直接検証および解析できるようにします。
JWTアクセストークンの概要
なぜJWTアクセストークンが必要なのか?
従来のアクセストークンの問題点:
不透明トークン (Opaque Token):
- 形式: ランダム文字列 (例: "SlAV32hkKG...XPw")
- 特徴: 直接解析できない
- 検証: イントロスペクションエンドポイントを呼び出す必要がある (RFC 7662)
問題点:
❌ 各APIコールでイントロスペクションが必要 → 追加のネットワークリクエスト
❌ 認可サーバーへの高負荷 → パフォーマンスのボトルネック
❌ レイテンシの増加 → ユーザー体験の悪化
❌ 単一障害点 → 認可サーバーの障害がすべてのAPIに影響
フロー例:
クライアント → [access_token] → リソースサーバー
↓
イントロスペクションエンドポイント ← 認可サーバー
↓
レスポンス (active: true)
↓
検証成功
JWTアクセストークンの利点:
JWTトークン:
- 形式: 自己完結型のJSON Web Token
- 特徴: 直接検証および解析可能
- 検証: 公開鍵を使用した署名検証
利点:
✓ イントロスペクション不要 → ゼロ追加リクエスト
✓ 認可サーバーの負荷軽減 → 高性能
✓ 低レイテンシ → 高速レスポンス
✓ オフライン検証 → 認可サーバー障害がAPIに影響しない
✓ コンテキスト情報を含む → scope、ユーザーIDなど
フロー例:
クライアント → [JWT access_token] → リソースサーバー
↓
1. 署名検証 (公開鍵使用)
2. 有効期限チェック
3. audience検証
↓
検証成功
↓
JWTからscope/ユーザーIDを読取
JWTアクセストークン vs 不透明トークン
比較:
不透明トークン (Opaque Token):
利点:
+ いつでも取消可能 (サーバー側制御)
+ 小さいトークン (通常20-40文字)
+ 情報漏洩なし (解析不可)
欠点:
- イントロスペクションで検証必須 (ネットワークオーバーヘッド)
- 認可サーバーへの高負荷
- オフライン検証不可
適用シナリオ:
- 即時取消が必要なシナリオ
- 高セキュリティ要件 (トークンを解析不可にしたい)
- 短期トークン
JWTアクセストークン:
利点:
+ 自己完結型 (イントロスペクション不要)
+ 高性能 (オフライン検証)
+ コンテキストを含む (scope、claims)
+ 拡張可能
欠点:
- 大きいトークン (通常100-300+文字)
- 取消困難 (追加メカニズムが必要)
- 情報が可視 (base64デコードで読取可能)
適用シナリオ:
- 高性能API
- マイクロサービスアーキテクチャ
- 分散システム
- トークンにコンテキスト情報を含める必要がある
ハイブリッドソリューション:
- 短期JWTアクセストークン (例: 15分)
- 長期不透明リフレッシュトークン
→ 両者の利点を組み合わせ
JWTアクセストークンの形式
必須クレーム (Required Claims)
{
"iss": "https://authorization-server.example.com",
"exp": 1639533600,
"aud": "https://api.example.com",
"sub": "user-123",
"client_id": "client-456",
"iat": 1639530000,
"jti": "token-789"
}
1. iss (Issuer - 発行者):
"iss": "https://authorization-server.example.com"
説明:
- 認可サーバーの識別子
- HTTPS URLである必要がある
- 検証鍵の検索に使用
検証:
const payload = jwt.decode(token);
if (payload.iss !== expectedIssuer) {
throw new Error('Invalid issuer');
}
2. exp (Expiration Time - 有効期限):
"exp": 1639533600 // Unixタイムスタンプ
説明:
- トークンの有効期限
- Unixタイムスタンプ (秒)
- 未来の時刻である必要がある
検証:
const now = Math.floor(Date.now() / 1000);
if (payload.exp <= now) {
throw new Error('Token expired');
}
推奨有効期間:
- 短期: 5-15分 (高セキュリティ)
- 中期: 1時間 (バランス)
- 長期: 24時間 (低セキュリティ、非推奨)
3. aud (Audience - オーディエンス):
"aud": "https://api.example.com"
または
"aud": ["https://api.example.com", "https://api2.example.com"]
説明:
- トークンの対象リソースサーバー
- 文字列または文字列配列
- 意図しないリソースでのトークン使用を防止
検証:
const expectedAudience = 'https://api.example.com';
if (Array.isArray(payload.aud)) {
if (!payload.aud.includes(expectedAudience)) {
throw new Error('Invalid audience');
}
} else {
if (payload.aud !== expectedAudience) {
throw new Error('Invalid audience');
}
}
4. sub (Subject - サブジェクト):
"sub": "user-123"
説明:
- トークンのサブジェクト識別子
- 通常はユーザーID
- 発行者のスコープ内で一意である必要がある
- 文字列型
使用:
// トークンからユーザーIDを取得
const userId = payload.sub;
const user = await getUserById(userId);
5. client_id (Client Identifier - クライアント識別子):
"client_id": "client-456"
説明:
- トークンを要求するクライアントのID
- トラッキングと監査に使用
- 異なるクライアントのリクエストを区別
使用:
// クライアント権限をチェック
const client = await getClient(payload.client_id);
if (!client.hasPermission('write')) {
throw new Error('Client not authorized');
}
6. iat (Issued At - 発行時刻):
"iat": 1639530000 // Unixタイムスタンプ
説明:
- トークンの発行時刻
- Unixタイムスタンプ (秒)
- リプレイ攻撃の防止に使用
検証:
const now = Math.floor(Date.now() / 1000);
const maxAge = 3600; // 1時間
if (now - payload.iat > maxAge) {
throw new Error('Token too old');
}
7. jti (JWT ID - JWT識別子):
"jti": "token-789"
説明:
- トークンの一意識別子
- リプレイ攻撃の防止に使用
- トークン取消に使用可能
使用:
// トークンが取り消されているかチェック
const isRevoked = await checkRevokedToken(payload.jti);
if (isRevoked) {
throw new Error('Token has been revoked');
}
オプションクレーム (Optional Claims)
8. scope (Scope - スコープ):
"scope": "read write profile"
説明:
- 認可された権限範囲
- スペース区切りの文字列
- リソースサーバーはこれに基づいて権限制御を実行
使用:
const scopes = payload.scope.split(' ');
if (!scopes.includes('write')) {
return res.status(403).json({ error: 'Insufficient scope' });
}
9. カスタムクレーム:
{
"iss": "https://authorization-server.example.com",
"exp": 1639533600,
"aud": "https://api.example.com",
"sub": "user-123",
"client_id": "client-456",
// カスタムクレーム
"email": "[email protected]",
"name": "John Doe",
"roles": ["admin", "editor"],
"tenant_id": "tenant-789",
"permissions": ["read:articles", "write:articles"]
}
説明:
- 任意のカスタムクレームを追加可能
- コンテキスト情報の伝達に使用
- データベースクエリの削減
注意:
⚠️ トークンサイズ制限 (通常 < 8KB)
⚠️ 機密情報を含めない (トークンはデコード可能)
⚠️ ネットワーク転送コストを考慮
完全なJWT例
完全なJWTアクセストークン:
eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6IjEyMyJ9.eyJpc3MiOiJodHRwczovL2F1dGhvcml6YXRpb24tc2VydmVyLmV4YW1wbGUuY29tIiwiZXhwIjoxNjM5NTMzNjAwLCJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiY2xpZW50LTQ1NiIsImlhdCI6MTYzOTUzMDAwMCwianRpIjoidG9rZW4tNzg5Iiwic2NvcGUiOiJyZWFkIHdyaXRlIn0.signature
分解:
Header (ヘッダー):
{
"alg": "RS256",
"typ": "at+jwt", ← タイプ: アクセストークンJWT
"kid": "123"
}
Payload (ペイロード):
{
"iss": "https://authorization-server.example.com",
"exp": 1639533600,
"aud": "https://api.example.com",
"sub": "user-123",
"client_id": "client-456",
"iat": 1639530000,
"jti": "token-789",
"scope": "read write"
}
Signature (署名):
RSASSA-PKCS1-v1_5 using SHA-256
署名と検証
推奨アルゴリズム
RFC 9068の推奨:
優先推奨:
1. RS256 (RSA with SHA-256)
- 非対称暗号化
- 公開鍵検証
- 最も一般的に使用
2. ES256 (ECDSA with P-256 and SHA-256)
- 非対称暗号化
- 短い署名
- より良いパフォーマンス
非推奨:
❌ HS256 (HMAC with SHA-256)
- 対称暗号化
- 共有鍵が必要
- セキュリティが低い (すべてのリソースサーバーが鍵を必要とする)
なぜ非対称アルゴリズムが推奨されるか?
→ 認可サーバーは秘密鍵で署名
→ リソースサーバーは公開鍵で検証
→ 公開鍵は公開配布可能
→ 共有鍵不要
JWT生成 (認可サーバー)
const jose = require('jose');
class JWTAccessTokenIssuer {
constructor(privateKey, issuer, keyId) {
this.privateKey = privateKey;
this.issuer = issuer;
this.keyId = keyId;
}
async issueAccessToken(userId, clientId, audience, scope, expiresIn = 900) {
const now = Math.floor(Date.now() / 1000);
const payload = {
// 必須クレーム
iss: this.issuer,
exp: now + expiresIn, // デフォルト15分
aud: audience,
sub: userId,
client_id: clientId,
iat: now,
jti: this.generateJti(),
// オプションクレーム
scope: scope
};
// JWT生成
const jwt = await new jose.SignJWT(payload)
.setProtectedHeader({
alg: 'RS256',
typ: 'at+jwt', // 重要: アクセストークンとして識別
kid: this.keyId
})
.sign(this.privateKey);
return jwt;
}
generateJti() {
return require('crypto').randomBytes(16).toString('hex');
}
}
// 使用例
const { generateKeyPair } = require('jose');
const { privateKey, publicKey } = await generateKeyPair('RS256');
const issuer = new JWTAccessTokenIssuer(
privateKey,
'https://authorization-server.example.com',
'key-123'
);
const accessToken = await issuer.issueAccessToken(
'user-123', // userId
'client-456', // clientId
'https://api.example.com', // audience
'read write', // scope
900 // 15分
);
console.log('Access Token:', accessToken);
JWT検証 (リソースサーバー)
const jose = require('jose');
class JWTAccessTokenValidator {
constructor(issuer, audience) {
this.issuer = issuer;
this.audience = audience;
this.jwksCache = null;
this.jwksCacheTime = 0;
}
async validate(token) {
try {
// ステップ1: JWKS取得 (公開鍵セット)
const jwks = await this.getJWKS();
// ステップ2: JWT検証
const { payload, protectedHeader } = await jose.jwtVerify(
token,
jwks,
{
issuer: this.issuer,
audience: this.audience,
typ: 'at+jwt' // タイプ確認
}
);
// ステップ3: 追加検証
this.validatePayload(payload);
return {
valid: true,
payload,
header: protectedHeader
};
} catch (err) {
return {
valid: false,
error: err.message
};
}
}
async getJWKS() {
// JWKSを1時間キャッシュ
const now = Date.now();
if (this.jwksCache && (now - this.jwksCacheTime < 3600000)) {
return this.jwksCache;
}
// 認可サーバーからJWKSを取得
const jwksUrl = `${this.issuer}/.well-known/jwks.json`;
const jwks = jose.createRemoteJWKSet(new URL(jwksUrl));
this.jwksCache = jwks;
this.jwksCacheTime = now;
return jwks;
}
validatePayload(payload) {
// 必須クレームをチェック
const required = ['iss', 'exp', 'aud', 'sub', 'client_id', 'iat', 'jti'];
for (const claim of required) {
if (!payload[claim]) {
throw new Error(`Missing required claim: ${claim}`);
}
}
// client_id形式をチェック
if (typeof payload.client_id !== 'string') {
throw new Error('client_id must be a string');
}
// その他のカスタム検証を追加可能...
}
// scopeを抽出
getScopes(payload) {
if (!payload.scope) return [];
return payload.scope.split(' ');
}
// 権限をチェック
hasScope(payload, requiredScope) {
const scopes = this.getScopes(payload);
return scopes.includes(requiredScope);
}
}
// 使用例
const validator = new JWTAccessTokenValidator(
'https://authorization-server.example.com',
'https://api.example.com'
);
// アクセストークンを検証
const result = await validator.validate(accessToken);
if (result.valid) {
console.log('Token is valid');
console.log('User ID:', result.payload.sub);
console.log('Client ID:', result.payload.client_id);
console.log('Scopes:', result.payload.scope);
} else {
console.error('Token is invalid:', result.error);
}
Expressミドルウェア実装
JWT検証ミドルウェア
const express = require('express');
const jose = require('jose');
// JWT検証ミドルウェア
function jwtAuthMiddleware(options = {}) {
const {
issuer,
audience,
requiredScopes = []
} = options;
const validator = new JWTAccessTokenValidator(issuer, audience);
return async (req, res, next) => {
try {
// ステップ1: トークンを抽出
const token = extractToken(req);
if (!token) {
return res.status(401).json({
error: 'invalid_request',
error_description: 'Missing access token'
});
}
// ステップ2: トークンを検証
const result = await validator.validate(token);
if (!result.valid) {
return res.status(401).json({
error: 'invalid_token',
error_description: result.error
});
}
// ステップ3: scopeをチェック
if (requiredScopes.length > 0) {
const hasAllScopes = requiredScopes.every(scope =>
validator.hasScope(result.payload, scope)
);
if (!hasAllScopes) {
return res.status(403).json({
error: 'insufficient_scope',
error_description: `Required scopes: ${requiredScopes.join(', ')}`
});
}
}
// ステップ4: リクエストオブジェクトに追加
req.auth = {
token: result.payload,
userId: result.payload.sub,
clientId: result.payload.client_id,
scopes: validator.getScopes(result.payload)
};
next();
} catch (err) {
console.error('Auth middleware error:', err);
res.status(500).json({
error: 'server_error',
error_description: 'Internal server error'
});
}
};
}
// トークン抽出ヘルパー関数
function extractToken(req) {
const authHeader = req.headers.authorization;
if (!authHeader) {
return null;
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return null;
}
return parts[1];
}
// 使用例
const app = express();
// JWT検証を設定
const jwtAuth = jwtAuthMiddleware({
issuer: 'https://authorization-server.example.com',
audience: 'https://api.example.com'
});
// 公開エンドポイント (認証不要)
app.get('/api/public', (req, res) => {
res.json({ message: 'Public endpoint' });
});
// 保護されたエンドポイント (認証必要)
app.get('/api/protected', jwtAuth, (req, res) => {
res.json({
message: 'Protected endpoint',
user: req.auth.userId,
client: req.auth.clientId
});
});
// 特定scopeが必要なエンドポイント
app.post('/api/articles',
jwtAuthMiddleware({
issuer: 'https://authorization-server.example.com',
audience: 'https://api.example.com',
requiredScopes: ['write', 'articles']
}),
(req, res) => {
res.json({
message: 'Article created',
author: req.auth.userId
});
}
);
// 動的scopeチェック
app.delete('/api/articles/:id', jwtAuth, async (req, res) => {
const article = await getArticle(req.params.id);
// 権限チェック: 管理者または著者本人
const isAdmin = req.auth.scopes.includes('admin');
const isAuthor = article.authorId === req.auth.userId;
if (!isAdmin && !isAuthor) {
return res.status(403).json({
error: 'insufficient_permissions',
error_description: 'You can only delete your own articles'
});
}
await deleteArticle(req.params.id);
res.json({ message: 'Article deleted' });
});
app.listen(3000, () => {
console.log('API server running on http://localhost:3000');
});
Scope検証デコレーター
// 高度なscope検証
class ScopeValidator {
// すべてのscopeが必要 (AND論理)
static requireAll(...scopes) {
return (req, res, next) => {
const hasAll = scopes.every(scope =>
req.auth.scopes.includes(scope)
);
if (!hasAll) {
return res.status(403).json({
error: 'insufficient_scope',
error_description: `Required scopes: ${scopes.join(' AND ')}`
});
}
next();
};
}
// いずれかのscopeが必要 (OR論理)
static requireAny(...scopes) {
return (req, res, next) => {
const hasAny = scopes.some(scope =>
req.auth.scopes.includes(scope)
);
if (!hasAny) {
return res.status(403).json({
error: 'insufficient_scope',
error_description: `Required scopes: ${scopes.join(' OR ')}`
});
}
next();
};
}
// 複雑な式
static requireExpression(expression) {
return (req, res, next) => {
const scopes = req.auth.scopes;
// 例: "(read AND write) OR admin"
const result = evaluateExpression(expression, scopes);
if (!result) {
return res.status(403).json({
error: 'insufficient_scope',
error_description: `Required: ${expression}`
});
}
next();
};
}
}
// 使用
app.get('/api/data',
jwtAuth,
ScopeValidator.requireAll('read', 'data'),
(req, res) => {
// read AND dataが必要
}
);
app.post('/api/admin/users',
jwtAuth,
ScopeValidator.requireAny('admin', 'super_admin'),
(req, res) => {
// admin OR super_adminが必要
}
);
トークン取消戦略
問題と解決策
JWT取消のジレンマ:
問題:
JWTは自己完結型で、リソースサーバーは認可サーバーに問い合わせない
→ トークンを即座に取り消せない
シナリオ:
1. ユーザーがログアウト
2. パスワードが変更される
3. 権限が取り消される
4. セキュリティインシデント
解決策:
解決策1: 短期トークン
// 短期アクセストークンを発行 (5-15分)
const accessToken = await issuer.issueAccessToken(
userId,
clientId,
audience,
scope,
900 // 15分
);
利点:
✓ シンプルな実装
✓ 自動的に早期失効
✓ 取消ウィンドウが小さい
欠点:
- 頻繁なリフレッシュが必要
- ユーザー体験への影響
解決策2: 取消リスト (Revocation List)
class TokenRevocationList {
constructor() {
this.revokedTokens = new Set();
// 本番環境ではRedisなどの分散キャッシュを使用すべき
}
// トークンを取り消す
revoke(jti, expiresAt) {
this.revokedTokens.add(jti);
// 有効期限を設定 (トークン失効後に自動クリーンアップ)
setTimeout(() => {
this.revokedTokens.delete(jti);
}, (expiresAt * 1000) - Date.now());
}
// 取り消されているかチェック
isRevoked(jti) {
return this.revokedTokens.has(jti);
}
}
const revocationList = new TokenRevocationList();
// 検証ミドルウェアでチェック
async function checkRevocation(req, res, next) {
const jti = req.auth.token.jti;
if (revocationList.isRevoked(jti)) {
return res.status(401).json({
error: 'invalid_token',
error_description: 'Token has been revoked'
});
}
next();
}
// 使用
app.use('/api', jwtAuth, checkRevocation);
解決策3: バージョン番号/タイムスタンプ
// ユーザーテーブルにフィールドを追加
{
userId: '123',
tokenVersion: 5, // ログアウト/パスワード変更時に増加
// または
tokensInvalidBefore: 1639530000 // この時刻以前のトークンは無効
}
// トークン生成時にバージョン番号を含める
const accessToken = await issuer.issueAccessToken(
userId,
clientId,
audience,
scope,
900,
{ token_version: user.tokenVersion } // カスタムクレーム
);
// 検証時にチェック
async function checkTokenVersion(req, res, next) {
const userId = req.auth.userId;
const tokenVersion = req.auth.token.token_version;
const user = await getUserById(userId);
if (tokenVersion < user.tokenVersion) {
return res.status(401).json({
error: 'invalid_token',
error_description: 'Token version outdated'
});
}
next();
}
解決策4: ハイブリッドソリューション (推奨)
// 複数の戦略を組み合わせる
class HybridRevocationStrategy {
constructor() {
this.shortTermRevocations = new Set(); // 最近の取消 (Redis)
this.userVersions = new Map(); // ユーザートークンバージョン (データベース)
}
async isValid(payload) {
// チェック1: JTIが取消リストにあるか
if (this.shortTermRevocations.has(payload.jti)) {
return false;
}
// チェック2: トークンバージョン
const userVersion = await this.getUserTokenVersion(payload.sub);
if (payload.token_version < userVersion) {
return false;
}
// チェック3: タイムスタンプ
const invalidBefore = await this.getUserTokensInvalidBefore(payload.sub);
if (payload.iat < invalidBefore) {
return false;
}
return true;
}
async revokeToken(jti, expiresAt) {
// 短期取消 (トークン失効まで)
this.shortTermRevocations.add(jti);
setTimeout(() => {
this.shortTermRevocations.delete(jti);
}, (expiresAt * 1000) - Date.now());
}
async revokeAllUserTokens(userId) {
// ユーザートークンバージョンを増加
const currentVersion = await this.getUserTokenVersion(userId);
await this.setUserTokenVersion(userId, currentVersion + 1);
}
}
パフォーマンス最適化
JWKSキャッシング
class JWKSCache {
constructor(jwksUrl, cacheDuration = 3600000) {
this.jwksUrl = jwksUrl;
this.cacheDuration = cacheDuration;
this.cache = null;
this.cacheTime = 0;
}
async getJWKS() {
const now = Date.now();
// キャッシュをチェック
if (this.cache && (now - this.cacheTime < this.cacheDuration)) {
return this.cache;
}
// 新しいJWKSを取得
try {
const response = await fetch(this.jwksUrl);
const jwks = await response.json();
this.cache = jwks;
this.cacheTime = now;
return jwks;
} catch (err) {
// 取得失敗だが古いキャッシュがある場合、継続使用
if (this.cache) {
console.warn('JWKS fetch failed, using cached version');
return this.cache;
}
throw err;
}
}
invalidate() {
this.cache = null;
this.cacheTime = 0;
}
}
検証結果のキャッシング
class TokenValidationCache {
constructor(cacheDuration = 60000) { // 1分
this.cache = new Map();
this.cacheDuration = cacheDuration;
}
get(token) {
const entry = this.cache.get(token);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(token);
return null;
}
return entry.result;
}
set(token, result) {
this.cache.set(token, {
result,
expiresAt: Date.now() + this.cacheDuration
});
// 定期的なクリーンアップ
this.cleanup();
}
cleanup() {
const now = Date.now();
for (const [token, entry] of this.cache.entries()) {
if (now > entry.expiresAt) {
this.cache.delete(token);
}
}
}
}
// 使用
const validationCache = new TokenValidationCache();
async function validateWithCache(token, validator) {
// キャッシュをチェック
let result = validationCache.get(token);
if (!result) {
// トークンを検証
result = await validator.validate(token);
// 有効なトークンのみキャッシュ
if (result.valid) {
validationCache.set(token, result);
}
}
return result;
}
ベストプラクティス
1. トークンサイズの最適化
// ❌ 悪い: トークンが大きすぎる
{
"iss": "https://authorization-server.example.com",
"exp": 1639533600,
"aud": "https://api.example.com",
"sub": "user-123",
"client_id": "client-456",
"iat": 1639530000,
"jti": "token-789",
"scope": "read write",
// カスタムクレームが多すぎる
"email": "[email protected]",
"full_name": "John Robert Smith Junior",
"profile_picture": "https://cdn.example.com/users/123/profile/large.jpg",
"address": {
"street": "123 Main Street",
"city": "New York",
"state": "NY",
"zip": "10001",
"country": "USA"
},
"preferences": { /* 大量データ */ }
}
// 結果: トークン > 2KB
// ✓ 良い: トークンが簡潔
{
"iss": "https://auth.example.com", // 短いドメイン
"exp": 1639533600,
"aud": "https://api.example.com",
"sub": "user-123",
"client_id": "client-456",
"iat": 1639530000,
"jti": "token-789",
"scope": "read write",
// 必要なカスタムクレームのみ含める
"roles": ["admin"]
}
// 結果: トークン < 500バイト
戦略:
1. 短い発行者URLを使用
2. カスタムクレームを最小化
3. 大量データはサーバー側に保存し、トークンには参照のみ含める
4. 詳細な権限ではなくロールを使用
2. セキュリティ
// ✓ 正しいセキュリティ設定
const securityConfig = {
// 短期トークン
accessTokenTTL: 900, // 15分
// 非対称アルゴリズムを使用
algorithm: 'RS256',
// typヘッダー
tokenType: 'at+jwt',
// 具体的なaudience
audience: 'https://api.example.com', // ワイルドカードを使用しない
// HTTPSを強制
requireHTTPS: true,
// クロックスキュー許容値
clockTolerance: 60 // 1分
};
// ❌ 安全でない設定
const insecureConfig = {
accessTokenTTL: 86400, // 24時間 - 長すぎ!
algorithm: 'HS256', // 対称アルゴリズム - 非推奨!
tokenType: 'JWT', // 不明確
audience: '*', // ワイルドカード - 危険!
requireHTTPS: false // HTTP - 安全でない!
};
3. エラー処理
// 包括的なエラー処理
class JWTErrorHandler {
static handle(err, req, res, next) {
if (err.name === 'JWTExpired') {
return res.status(401).json({
error: 'invalid_token',
error_description: 'The access token expired',
error_uri: 'https://docs.example.com/errors/token-expired'
});
}
if (err.name === 'JWTClaimValidationFailed') {
return res.status(401).json({
error: 'invalid_token',
error_description: `Token validation failed: ${err.claim}`,
error_uri: 'https://docs.example.com/errors/invalid-token'
});
}
if (err.name === 'JWSSignatureVerificationFailed') {
return res.status(401).json({
error: 'invalid_token',
error_description: 'Token signature verification failed'
});
}
// 一般的なエラー
console.error('JWT error:', err);
res.status(401).json({
error: 'invalid_token',
error_description: 'Token validation failed'
});
}
}
app.use(JWTErrorHandler.handle);
まとめ
重要なポイント
✓ JWTアクセストークンは自己完結型
✓ イントロスペクション不要 → 高性能
✓ オフライン検証 → スケーラブル
✓ コンテキストを含む → データベースクエリの削減
✓ 標準化された形式 → 相互運用性
必須クレーム:
- iss, exp, aud, sub, client_id, iat, jti
推奨プラクティス:
✓ RS256またはES256アルゴリズムを使用
✓ 短期トークン (5-15分)
✓ typヘッダーを"at+jwt"に設定
✓ 具体的なaudience
✓ トークンサイズを最小化
✓ 取消戦略を実装
パフォーマンス最適化:
✓ JWKSキャッシング
✓ 検証結果キャッシング
✓ 非同期検証
セキュリティ考慮事項:
✓ HTTPSを強制
✓ すべてのクレームを検証
✓ 取消メカニズムを実装
✓ 異常なトークンを監視
参考文献
関連RFC:
- [RFC 9068] JWT Profile for OAuth 2.0 Access Tokens ← 本文書
- [RFC 7519] JSON Web Token (JWT)
- [RFC 7515] JSON Web Signature (JWS)
- [RFC 6749] OAuth 2.0 Authorization Framework
- [RFC 7662] OAuth 2.0 Token Introspection
関連ライブラリ:
- jose - JavaScript JWTライブラリ
- jsonwebtoken - Node.js JWTライブラリ
まとめ: RFC 9068は、OAuth 2.0アクセストークンにJWTを使用する標準形式を定義しています。自己完結型とオフライン検証により、APIのパフォーマンスとスケーラビリティが大幅に向上します。短期トークンと適切な取消戦略を組み合わせることで、高性能を維持しながらセキュリティを確保できます!