JWT トークンの解説: 構造、セキュリティ、ベストプラクティス

· 12分で読めます

JSON Web Token (JWT) は、現代のウェブアプリケーションにおけるステートレス認証の事実上の標準となっています。REST API、マイクロサービスアーキテクチャ、シングルページアプリケーションのいずれを構築する場合でも、安全でスケーラブルな認証を実装するには JWT の仕組みを理解することが不可欠です。

この包括的なガイドでは、JWT について知っておくべきすべてのこと、内部構造から本番環境に対応したセキュリティプラクティスまでを詳しく説明します。最後まで読めば、JWT の使い方だけでなく、いつ使うべきか、そしてセキュリティ脆弱性につながる一般的な落とし穴を回避する方法を理解できるでしょう。

目次

JWT とは?

JSON Web Token (JWT) は、2つの当事者間で転送されるクレームを表現するための、コンパクトで URL セーフな手段です。JWT 内のクレームは、JSON Web Signature (JWS) を使用してデジタル署名された JSON オブジェクトとしてエンコードされます。

JWT は、ユーザー情報のための改ざん防止コンテナと考えてください。ユーザーがログインすると、サーバーはユーザー ID、ロール、その他の関連データを含む JWT を作成します。このトークンはクライアントに送信され、クライアントはその後のすべてのリクエストにそれを含めます。サーバーはデータベースにクエリを実行することなくトークンの真正性を検証できるため、JWT はステートレスでスケーラブルなアーキテクチャに最適です。

JWT は RFC 7519 で定義されており、プログラミング言語やフレームワーク全体で広くサポートされています。特に以下の用途で人気があります:

クイックヒント: 当社の JWT デコーダー を使用して、任意の JWT トークンを即座に検査およびデバッグできます。完全にクライアント側で動作するため、トークンがブラウザから外に出ることはありません。

JWT の構造: ヘッダー、ペイロード、署名

JWT は、ドット (.) で区切られた3つの Base64URL エンコードされた部分で構成されています。実際の JWT は次のようになります:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

これを分解すると:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9           ← ヘッダー
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ  ← ペイロード
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c    ← 署名
部分 内容 例 (デコード後)
ヘッダー アルゴリズム + トークンタイプ {"alg": "HS256", "typ": "JWT"}
ペイロード クレーム (ユーザーデータ) {"sub": "1234567890", "name": "John Doe", "iat": 1516239022}
署名 検証ハッシュ HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

ヘッダー

ヘッダーは通常、トークンタイプ (typ) と署名アルゴリズム (alg) の2つの部分で構成されます。一般的なアルゴリズムには以下があります:

ペイロード

ペイロードにはクレーム、つまりエンティティ (通常はユーザー) に関するステートメントと追加のメタデータが含まれます。クレームは暗号化されておらず、エンコードされているだけなので、パスワードなどの機密情報を JWT ペイロードに入れないでください。

署名

署名は、トークンが改ざんされていないことを保証します。エンコードされたヘッダー、エンコードされたペイロード、秘密鍵を取得し、ヘッダーで指定されたアルゴリズムを適用することで作成されます。誰かがヘッダーまたはペイロードを変更すると、署名検証は失敗します。

プロのヒント: 当社の JWT ジェネレーター ツールを使用して、カスタムクレームを持つテスト JWT を生成できます。開発およびテストシナリオに最適です。

標準クレーム (RFC 7519)

RFC 7519 は、異なる JWT 実装間の相互運用性を提供するいくつかの標準クレームを定義しています。これらのクレームはオプションですが、正しく使用することでセキュリティと互換性が向上します。

クレーム 名称 目的
iss 発行者 トークンを作成した者を識別 "https://auth.example.com"
sub サブジェクト トークンが誰に関するものかを識別 (通常はユーザー ID) "user_12345"
aud オーディエンス トークンが誰を対象としているかを識別 "https://api.example.com"
exp 有効期限 トークンが期限切れになる時刻 (Unix タイムスタンプ) 1735689600
nbf 有効開始時刻 この時刻より前はトークンが無効 1735603200
iat 発行時刻 トークンが作成された時刻 1735603200
jti JWT ID トークンの一意識別子 (失効に便利) "a1b2c3d4-e5f6"

カスタムクレーム

標準クレームに加えて、アプリケーション固有のカスタムクレームを追加できます。一般的な例には以下があります:

ペイロードは小さく保ちましょう。すべてのバイトがネットワークオーバーヘッドに追加されます。認可決定に必要なクレームのみを含めてください。追加のユーザーデータが必要な場合は、sub クレームを検索キーとして使用してデータベースから取得してください。

JWT 認証の仕組み

完全な認証フローを理解することで、JWT を正しく安全に実装できます。典型的なシーケンスは次のとおりです:

  1. ユーザーログイン: ユーザーが認証エンドポイントに認証情報 (ユーザー名 + パスワード) を送信
  2. 認証情報の検証: サーバーがユーザーデータベースに対して認証情報を検証
  3. トークン生成: サーバーがユーザークレームを含む JWT を作成し、秘密鍵で署名
  4. トークン配信: サーバーが JWT をクライアントに返す (通常はレスポンスボディまたは httpOnly クッキーとして)
  5. トークン保存: クライアントが JWT を安全に保存 (詳細は「保存オプション」セクションを参照)
  6. 認証済みリクエスト: クライアントが後続のリクエストの Authorization ヘッダーに JWT を含める
  7. トークン検証: サーバーが署名を検証し、有効期限をチェック。データベース検索は不要
  8. アクセス許可: 有効な場合、サーバーはトークンからのクレームを使用してリクエストを処理

視覚的な表現は次のとおりです:

┌────────┐                                  ┌────────┐
│クライアント│                                  │サーバー│
└───┬────┘                                  └───┬────┘
    │                                           │
    │  POST /login {username, password}         │
    │──────────────────────────────────────────>│
    │                                           │
    │                                    [認証情報を検証]
    │                                    [JWT を生成]
    │                                           │
    │  200 OK {token: "eyJhbG..."}              │
    │<──────────────────────────────────────────│
    │                                           │
[トークンを保存]                                 │
    │                                           │
    │  GET /api/profile                         │
    │  Authorization: Bearer eyJhbG...          │
    │──────────────────────────────────────────>│
    │                                           │
    │                                    [署名を検証]
    │                                    [有効期限をチェック]
    │                                    [クレームを抽出]
    │                                           │
    │  200 OK {user data}                       │
    │<──────────────────────────────────────────│
    │                                           │

プロのヒント: JWT の美しさは、ステップ7 (トークン検証) がデータベースクエリを必要としないことです。サーバーはトークンから直接真正性を検証し、ユーザー情報を抽出できるため、API の高いスケーラビリティが実現します。

実装例

人気のあるプログラミング言語とフレームワークでの実用的な実装を見てみましょう。

Node.js と jsonwebtoken

const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');

// ログインエンドポイント
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  
  // データベースでユーザーを検索
  const user = await User.findOne({ username });
  if (!user) {
    return res.status(401).json({ error: '認証情報が無効です' });
  }
  
  // パスワードを検証
  const validPassword = await bcrypt.compare(password, user.passwordHash);
  if (!validPassword) {
    return res.status(401).json({ error: '認証情報が無効です' });
  }
  
  // JWT を作成
  const token = jwt.sign(
    { 
      sub: user.id,
      email: user.email,
      role: user.role 
    },
    process.env.JWT_SECRET,
    { 
      expiresIn: '15m',
      issuer: 'https://api.example.com',
      audience: 'https://example.com'
    }
  );
  
  res.json({ token });
});

// 保護されたルートのミドルウェア
const authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
  
  if (!token) {
    return res.status(401).json({ error: 'トークンが提供されていません' });
  }
  
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(403).json({ error: 'トークンが無効または期限切れです' });
  }
};

// ミドルウェアを使用
app.get('/api/profile', authenticateToken, (req, res) => {
  res.json({ userId: req.user.sub, email: req.user.email });
});

Python と PyJWT

import jwt
import datetime
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET_KEY = 'your-secret-key'

@app.route('/login', methods=['POST'])
def login():
    data = request.get_json()
    username = data.get('username')
    password = data.get('password')
    
    # 認証情報を検証 (簡略化)
    user = verify_credentials(username, password)
    if not user:
        return jsonify({'error': '認証情報が無効です'}), 401
    
    # JWT を作成
    payload = {
        'sub': user['id'],
        'email': user['email'],
        'role': user['role'],
        'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=15),
        'iat': datetime.datetime.utcnow()
    }
    
    token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')
    return jsonify({'token': token})

def token_required(f):
    def decorator(*args, **kwargs):
        token = request.headers.get('Authorization')
        
        if not token:
            return jsonify({'error': 'トークンが提供されていません'}), 401
        
        try:
            # 'Bearer ' プレフィックスを削除
            token = token.split(' ')[1]
            decoded = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
            request.user = decoded
        except jwt.ExpiredSignatureError:
            return jsonify({'error': 'トークンが期限切れです'}), 403
        except jwt.InvalidTokenError:
            return jsonify({'error': 'トークンが無効です'}), 403
        
        return f(*args, **kwargs)
    
    return decorator

@app.route('/api/profile')
@token_required
def profile():
    return jsonify({'userId': request.user['sub']})

Go と golang-jwt

package main

import (
    "time"
    "github.com/golang-jwt/jwt/v5"
)

type Claims struct {
    UserID string `json:"sub"`
    Email  string `json:"email"`
    Role   string `json:"role"`
    jwt.RegisteredClaims
}

func generateToken(userID, email, role string) (string, error) {
    claims := Claims{
        UserID: userID,
        Email:  emai