メインコンテンツまでスキップ

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