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 で定義されており、プログラミング言語やフレームワーク全体で広くサポートされています。特に以下の用途で人気があります:
- シングルサインオン (SSO) システム - ユーザーが一度認証すれば複数のサービスにアクセスできる
- マイクロサービスアーキテクチャ - サービスが共有セッションストレージなしでユーザー ID を検証する必要がある
- モバイルアプリケーション - サーバー側のセッションを維持することが現実的でない
- API 認証 - ステートレス検証がスケーラビリティを向上させる
クイックヒント: 当社の 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つの部分で構成されます。一般的なアルゴリズムには以下があります:
- HS256 (HMAC with SHA-256): 共有シークレットを使用する対称アルゴリズム
- RS256 (RSA with SHA-256): 公開鍵/秘密鍵ペアを使用する非対称アルゴリズム
- ES256 (ECDSA with SHA-256): より小さな鍵サイズの非対称アルゴリズム
ペイロード
ペイロードにはクレーム、つまりエンティティ (通常はユーザー) に関するステートメントと追加のメタデータが含まれます。クレームは暗号化されておらず、エンコードされているだけなので、パスワードなどの機密情報を 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" |
カスタムクレーム
標準クレームに加えて、アプリケーション固有のカスタムクレームを追加できます。一般的な例には以下があります:
roleまたはroles: ユーザー権限 (例:"admin"、"user")email: ユーザーのメールアドレスname: ユーザーの表示名scope: API アクセスのための OAuth 2.0 スコープtenant_id: マルチテナントアプリケーション識別子
ペイロードは小さく保ちましょう。すべてのバイトがネットワークオーバーヘッドに追加されます。認可決定に必要なクレームのみを含めてください。追加のユーザーデータが必要な場合は、sub クレームを検索キーとして使用してデータベースから取得してください。
JWT 認証の仕組み
完全な認証フローを理解することで、JWT を正しく安全に実装できます。典型的なシーケンスは次のとおりです:
- ユーザーログイン: ユーザーが認証エンドポイントに認証情報 (ユーザー名 + パスワード) を送信
- 認証情報の検証: サーバーがユーザーデータベースに対して認証情報を検証
- トークン生成: サーバーがユーザークレームを含む JWT を作成し、秘密鍵で署名
- トークン配信: サーバーが JWT をクライアントに返す (通常はレスポンスボディまたは httpOnly クッキーとして)
- トークン保存: クライアントが JWT を安全に保存 (詳細は「保存オプション」セクションを参照)
- 認証済みリクエスト: クライアントが後続のリクエストの
Authorizationヘッダーに JWT を含める - トークン検証: サーバーが署名を検証し、有効期限をチェック。データベース検索は不要
- アクセス許可: 有効な場合、サーバーはトークンからのクレームを使用してリクエストを処理
視覚的な表現は次のとおりです:
┌────────┐ ┌────────┐
│クライアント│ │サーバー│
└───┬────┘ └───┬────┘
│ │
│ 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