JWTの裏側:なぜ「ただの文字列」が世界中の認証を破壊するのか
現場でセキュリティ診断をしていると、開発者が「JWTは暗号化されているから安全」と誤解している場面に何度も遭遇する。はっきり言おう。JWTは暗号化などされていない。ただのBase64エンコードされたJSONだ。
中身は誰でもデコードして読めるし、署名を突破された瞬間に、攻撃者は「管理者」としてシステムに君臨する。今日は、JWTを巡る「穴」と、それを塞ぐための防壁を、現場のリアリティを交えて解説する。
—
1. 攻撃者が狙うJWTの3つの盲点
攻撃者は、JWTの構造を「検証する側」の甘い実装に漬け込む。特に以下の3つが頻出する脆弱性だ。
① alg: "none" 攻撃
JWTのヘッダーにある alg フィールドを none に書き換える手法だ。一部の古いライブラリや設定ミスがある実装では、alg: "none" を指定すると「署名の検証をスキップ」してトークンを有効とみなしてしまう。
- 悪用: 攻撃者は
payloadを{"role": "admin"}に改ざんし、署名部分を空にしてサーバーへ送る。
② 弱いシークレットキーのブルートフォース
HS256(HMAC SHA-256)アルゴリズムを使用している場合、サーバー側が持つ「秘密鍵」が辞書攻撃に弱いと即座にゲームオーバーだ。password や secret といった推測可能なキーを使っていれば、数分でトークンは偽造される。
③ KID (Key ID) インジェクション
JWTヘッダーの kid パラメータを使って、サーバー上の任意のパスにあるファイルを読み込ませたり、悪意のあるキーを強制的に読み込ませたりする攻撃だ。OSコマンドインジェクションに近い挙動を誘発できる。
—
2. 脆弱な実装例(これだけはやるな)
まず、以下のPHPコードを見てほしい。これが脆弱性の典型だ。
// 非常に危険なコード:署名をまともに検証していない
$token = $_COOKIE['auth_token'];
$decoded = json_decode(base64_decode(explode('.', $token)[1]), true);
// 署名を検証せず、中身のroleだけを信じてアクセス権を付与している
if ($decoded['role'] === 'admin') {
// 管理画面へアクセス許可... これが最大のミス
}
このコードの何が問題か? base64_decode して取り出したデータを「サーバーが発行したものだ」という前提で無条件に信じている点だ。JWTライブラリを使わず、自前で文字列をパースしている実装は、例外なく「脆弱性の宝庫」だ。
—
3. セキュアな実装:堅牢なJWT運用の教科書
JWTを扱う際は、「標準ライブラリ以外は絶対に使わない」「署名検証は妥協しない」というルールを徹底すること。ここではPythonの PyJWT を使った、現場で推奨される実装例を示す。
Pythonによるセキュアな検証例
import jwt
# 秘密鍵は環境変数から読み込み、絶対にソースコードに直書きしない
SECRET_KEY = os.environ.get('JWT_SECRET_KEY')
ALGORITHM = 'HS256'
def verify_token(token):
try:
# algorithmsでアルゴリズムを明示的に指定(noneを拒否する)
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
# トークンの有効期限切れ
return {"error": "Token expired"}
except jwt.InvalidTokenError:
# 署名不一致や不正なトークン
return {"error": "Invalid token"}
ポイント
1. algorithms=[ALGORITHM]: ここで利用可能なアルゴリズムを限定することで、none や RS256 への勝手な切り替えを防ぐ。
2. ExpiredSignatureError: トークンの有効期限(exp クレーム)を必ずチェックし、短命なトークン運用を心がける。
—
4. インフラ側で守る:WAFの防御戦略
アプリケーションレイヤーだけでなく、NginxやWAFでも多層防御を行うべきだ。
NginxによるJWTフィルタリング(設定例):
Authorization ヘッダーが異常な形式である場合、アプリケーションに渡す前にブロックする。
# 異常なJWT形式を弾く設定例
if ($http_authorization ~* "none") {
return 403;
}
location /api/ {
# JWTの構造を簡易的にチェック
if ($http_authorization !~* "Bearer\s+[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+") {
return 401;
}
proxy_pass http://backend_app;
}
—
最後に:エンジニアへ伝えたいこと
JWTは、認証の「鍵」そのものだ。家を建てる時に、玄関の鍵を100円ショップの南京錠にする人はいないだろう? JWTも同じだ。署名を軽視することは、玄関を全開にして泥棒を招き入れるのと同じことだ。
1. 署名を信じるな、検証の結果を信じろ。
2. 秘密鍵は長く、ランダムに生成し、ローテーションせよ。
3. ライブラリを信頼し、自作のパーサーは捨てろ。
この3つを守るだけで、攻撃者の99%は諦めて別のターゲットを探しに行くだろう。それが、我々エンジニアが守るべきプロの境界線だ。
コメント