メインコンテンツへスキップ
Toolsbase Logo

JWT の構造・セキュリティリスク・ベストプラクティス完全ガイド

Toolsbase編集部
JWTRFC 7519OWASP認証セキュリティWeb開発

JWT の何が難しいのか

JWT(JSON Web Token)は理解しやすく見えて、誤った使い方をされやすいトークン形式です。

「JWT はステートレスで便利」という触れ込みでセッション管理に採用したが、ペイロードに個人情報を入れていた、アルゴリズムを none に差し替えられる脆弱性があった、ログアウトしてもトークンが有効なままだった——こうした問題は今も定期的に発生しています。

RFC 7519(JWT)、RFC 7515(JWS)、RFC 7516(JWE)の仕様を踏まえ、正確な構造と設計上の注意点を整理します。


JWT の 3 パート構造

JWT は 3 つの Base64url エンコードされた部分をドット(.)で結合したテキストです。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

各部分を分解します:

1. Header(ヘッダー)

{
  "alg": "HS256",
  "typ": "JWT"
}

alg は署名アルゴリズムを指定します。typ は通常 "JWT" です。

重要: Header は Base64url エンコードされているだけで暗号化されていません。誰でもデコードして中身を見られます。

2. Payload(ペイロード)

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622,
  "iss": "https://auth.example.com",
  "aud": "https://api.example.com"
}

登録済みクレーム(RFC 7519 Section 4.1):

クレーム 説明
iss Issuer — トークンを発行したエンティティ
sub Subject — トークンの主体(通常はユーザーID)
aud Audience — トークンの受信者(対象サービス)
exp Expiration Time — 有効期限(Unix タイムスタンプ)
nbf Not Before — この時刻以前は無効
iat Issued At — 発行時刻
jti JWT ID — トークンの一意識別子

Payload も暗号化されていません(JWS を使う場合)。Base64url デコードすれば誰でも読めます。

3. Signature(署名)

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

署名はトークンの改ざん検出に使われます。署名の検証に成功すれば「このトークンは発行者が生成したものであり、途中で改ざんされていない」ことが確認できます。

JWT のデコードには JWT デコーダー を使えます。


JWS と JWE の違い

JWT の仕様には 2 つの異なる保護方式があります。

仕様 形式 保護内容
JWS(RFC 7515) 署名付き JWT(一般的な JWT) 完全性(改ざん検出)。内容は可視
JWE(RFC 7516) 暗号化 JWT 完全性 + 機密性。内容は暗号化されて不可視

通常 JWT と呼ばれるものは JWS(署名付き)です。ペイロードを暗号化したい場合は JWE を使いますが、実装が複雑になるため、機密情報はトークンに含めないほうが根本的な解決策です。


HS256 vs RS256:署名アルゴリズムの選択

HS256(HMAC-SHA256)

対称鍵アルゴリズムです。同じ秘密鍵でトークンの生成検証を行います。

生成: HMACSHA256(header.payload, secret_key)
検証: HMACSHA256(header.payload, secret_key) を再計算して比較

問題: トークンを検証するすべてのサービスが同じ秘密鍵を持つ必要があります。マイクロサービス環境では秘密鍵の共有・管理が複雑になります。

RS256(RSA-SHA256)

非対称鍵アルゴリズムです。秘密鍵でトークンを生成し、公開鍵でトークンを検証します。

生成(認証サーバーのみ): RSA署名(header.payload, private_key)
検証(全APIサーバー): RSA検証(header.payload, signature, public_key)

利点: 公開鍵は文字通り公開できます。APIサーバーが公開鍵しか持たないため、APIサーバーが侵害されても新しいトークンを生成できません。

// Node.js: RS256 での JWT 生成と検証
const jwt = require('jsonwebtoken');
const fs = require('fs');

const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');

// 生成(認証サーバー)
const token = jwt.sign(
  { sub: 'user-123', iat: Math.floor(Date.now() / 1000) },
  privateKey,
  { algorithm: 'RS256', expiresIn: '1h', issuer: 'https://auth.example.com' }
);

// 検証(APIサーバー — 公開鍵のみ使用)
try {
  const decoded = jwt.verify(token, publicKey, {
    algorithms: ['RS256'],
    issuer: 'https://auth.example.com',
    audience: 'https://api.example.com',
  });
  console.log(decoded.sub); // 'user-123'
} catch (err) {
  // TokenExpiredError, JsonWebTokenError など
  console.error('Invalid token:', err.message);
}

推奨アルゴリズム

アルゴリズム 種別 推奨度 適した場面
RS256 RSA + SHA-256 ✓ 推奨 複数サービス・マイクロサービス
ES256 ECDSA + P-256 ✓ 推奨 RS256 より鍵が短く同等の安全性
HS256 HMAC-SHA256 △ 限定推奨 単一サービス・内部用途
RS512 / ES512 より大きな鍵 △ 要件次第 高セキュリティ要件がある場合

alg: none 脆弱性

JWT の最も有名な脆弱性の一つです。RFC 7519 は alg の値として none を許容しており(Section 6)、これは「署名なし」を意味します。

攻撃者が次の操作を行えるパーサーが実際に存在しました:

  1. 既存のトークンから Header と Payload を取得
  2. Header を {"alg": "none", "typ": "JWT"} に改ざん
  3. {"sub": "admin", "role": "admin", ...} に Payload を改ざん
  4. Signature 部分を空にして新しいトークンを作成
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiJ9.

対策:

// ❌ 危険: alg を検証しない
const decoded = jwt.verify(token, publicKey);

// ✅ 安全: 許可するアルゴリズムを明示指定
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],  // 'none' は含めない
});

ライブラリの選択でも重要です。jsonwebtoken(Node.js)は algorithms オプションを省略した場合でも none は受け入れませんが、古いバージョンや他の言語のライブラリでは注意が必要です。


ペイロードに秘密情報を入れてはいけない

JWT のペイロードはデコードするだけで読めます:

// ブラウザでも実行できる単純なデコード
function decodePayload(token) {
  const payload = token.split('.')[1];
  return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
}

// ローカルストレージに保存されたトークンのペイロードは誰でも読める

入れてはいけない情報:

  • パスワード、APIキー、秘密鍵
  • 個人情報(メールアドレス、電話番号、住所)
  • クレジットカード番号など

入れても良い情報:

  • ユーザー ID(サーバー側でデータを引き出すための識別子)
  • ロール("admin", "user" など)
  • スコープ("read:articles", "write:articles" など)
  • 有効期限、発行者

ステートレス vs ステートフル:JWT の制限

JWT が「ステートレス認証」と言われる理由は、サーバー側にセッション情報を保存せずにトークンだけで認証を完了できるためです。

ステートレス(JWT):
クライアント → [JWT] → サーバー → 署名検証 → OK
(データベース参照なし)

ステートフル(セッション):
クライアント → [Session ID] → サーバー → DB でセッション確認 → OK

ステートレスの制限:即時無効化ができない

「ユーザーのパスワードが変更された」「管理者がアカウントを無効化した」といった状況で、JWT の有効期限が切れるまで(例:1時間)トークンを無効化できません。

対策

  1. 有効期限を短くする(15〜30 分程度)とリフレッシュトークンを組み合わせる
  2. JWT ID(jti)の拒否リストを維持する(ただしステートレスのメリットが薄れる)
  3. トークンのバージョン管理:ユーザーレコードに token_version フィールドを持ち、JWT にも含める。パスワード変更時に token_version をインクリメント
// トークンバージョンによる無効化
async function verifyToken(token) {
  const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

  // DBからユーザーのトークンバージョンを確認
  const user = await db.users.findById(decoded.sub);
  if (user.token_version !== decoded.token_version) {
    throw new Error('Token revoked');
  }

  return decoded;
}

リフレッシュトークンの設計

アクセストークン(短命な JWT)とリフレッシュトークン(長命なランダムトークン)を分離するパターンが推奨されています。

アクセストークン: JWT、有効期限 15〜30 分
リフレッシュトークン: ランダムな不透明トークン、有効期限 7〜30 日、DBに保存
// リフレッシュトークンの発行
async function issueTokens(userId) {
  const accessToken = jwt.sign(
    { sub: userId, type: 'access' },
    privateKey,
    { algorithm: 'RS256', expiresIn: '15m' }
  );

  // リフレッシュトークンは JWT でなく乱数を使う
  const refreshToken = crypto.randomBytes(32).toString('hex');
  
  // リフレッシュトークンをDBに保存(ハッシュ化して)
  await db.refreshTokens.insert({
    token_hash: crypto.createHash('sha256').update(refreshToken).digest('hex'),
    user_id: userId,
    expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30日
  });

  return { accessToken, refreshToken };
}

// アクセストークンの更新
async function refreshAccessToken(refreshToken) {
  const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
  const stored = await db.refreshTokens.findOne({ token_hash: tokenHash });

  if (!stored || stored.expires_at < new Date()) {
    throw new Error('Invalid or expired refresh token');
  }

  // 古いリフレッシュトークンを無効化(ローテーション)
  await db.refreshTokens.delete({ token_hash: tokenHash });

  return issueTokens(stored.user_id);
}

セキュリティチェックリスト

OWASP の JWT Cheat Sheet に基づいた実装チェックリストです。

  • algorithms オプションで使用アルゴリズムを明示指定(none を絶対に含めない)
  • exp クレームを必ず設定し、有効期限切れを検証する
  • iss(発行者)と aud(対象)を検証する
  • ペイロードに秘密情報を含めない
  • アクセストークンの有効期限は 15〜30 分以内
  • リフレッシュトークンはローテーション(使用ごとに新しいトークンを発行)
  • HTTPS のみでトークンを送受信する
  • ブラウザの localStorage より httpOnly クッキーを優先する(XSS 対策)
  • JWK Set URL(jwks_uri)を使った公開鍵自動取得を設定する(RS256 の場合)

まとめ

JWT は正しく実装すれば強力な認証メカニズムです。特に重要な点:

  1. ペイロードは公開情報——暗号化には JWE を使う
  2. アルゴリズムを明示指定——alg: none 脆弱性を防ぐ
  3. 短命なアクセストークン + ローテーションするリフレッシュトークン
  4. 即時無効化が必要なら DB 参照を組み合わせる(純粋なステートレスは諦める)
  5. RS256 / ES256 を使う——マイクロサービスでは非対称鍵が安全

JWT の内容を確認・デバッグするには JWT デコーダー を使えます。


参考文献