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のパフォーマンスとスケーラビリティが大幅に向上します。短期トークンと適切な取消戦略を組み合わせることで、高性能を維持しながらセキュリティを確保できます!