【実務・中級編】 JWTの署名アルゴリズム(RS256 vs HS256)の選択基準 – Web APIアーキテクチャ・データ連携実践ガイド

JWTの署名アルゴリズム(RS256 vs HS256)選択の極意:APIゲートウェイの負荷とセキュリティのトレードオフを読み解く

こんにちは、インフラアーキテクトの私です。これまでに数々の大規模WebシステムやAPI基盤の設計・トラブルシューティングに立ち会ってきましたが、その中でも「認証・認可」の設計ミスによる手痛いインシデントは後を絶ちません。

特に、Web APIアーキテクチャにおいてデファクトスタンダードとなっているJWT(JSON Web Token)の署名アルゴリズム選定――具体的には HS256(HMAC-SHA256) と RS256(RSA Signature with SHA-256) のどちらを採用すべきかという問題は、単なる「暗号化方式の好み」ではなく、システム全体のセキュリティ境界(Trust Boundary) と APIゲートウェイのCPU負荷 を左右する極めて重大なアーキテクチャ判断です。

今回は、教科書的な仕様の丸暗記ではなく、パケットの往復やマイクロサービス間連携という「現場のリアル」を踏まえながら、この2つのアルゴリズムの選択基準を徹底的に紐解いていきましょう。

—

1. そもそもJWTの署名とは何を担保しているのか?

JWTは、Header、Payload、Signature の3つのパートがドット(.)で連結された、非常にシンプルな文字列構造を持っています。

xxxxx.yyyyy.zzzzz
(Header).(Payload).(Signature)

ここで勘違いしやすいのですが、JWTのデフォルト状態ではペイロードは暗号化されていません。Base64URLエンコードされているだけなので、インターネット上を流れるパケットをキャプチャされれば、誰でも中身のJSON(ユーザーIDや権限など)をデコードして読むことができます。

では、署名(Signature)は何のためにあるのでしょうか?
それは暗密性(秘匿性)ではなく、「改ざん検知(Integrity)」 と 「発行者の身元証明(Authenticity)」 です。
「このトークンは、信頼できる認証サーバーが発行したものであり、途中で悪意ある第三者に書き換えられていない」ということを数学的に担保するために署名が存在します。

この署名を作るためのアルゴリズムとして広く使われているのが、共通鍵を使う HS256 と、公開鍵/秘密鍵のペアを使う RS256 です。

—

2. HS256(対称鍵)vs RS256(非対称鍵)のメカニズムと特徴

それぞれの仕様と、実務での振る舞いを深掘りしてみましょう。

HS256 (HMAC using SHA-256)

  • 方式: 対称鍵暗号(Symmetric Cryptography)
  • 仕組み: トークンを「発行するサーバー」も、それを「検証するAPI / マイクロサービス」も、まったく同じ秘密の文字列(Shared Secret)を保持します。
  • メリット:
  • 暗号学的計算コストが非常に低い(CPU負荷が軽い)。
  • ライブラリの実装がシンプル。
  • デメリット:
  • 鍵の共有リスク: 検証を行うすべてのサーバーに同じ秘密鍵を配る必要があるため、1台のAPIサーバーがコンプロマイズ(侵害)された瞬間に、システム全体でトークンの偽造が可能になります。

RS256 (RSASSA-PKCS1-v1_5 using SHA-256)

  • 方式: 非対称鍵暗号(Asymmetric Cryptography)
  • 仕組み: トークンを発行する認証局(IdP)は秘密鍵(Private Key)を持ち、署名を行います。APIゲートウェイや各マイクロサービスは公開鍵(Public Key)だけを持ち、署名の検証を行います。
  • メリット:
  • ゼロ・トラストな設計: 検証側に秘密鍵を渡す必要がないため、APIサーバーがハッキングされてもトークンが偽造されるリスクはありません。
  • 異なる組織や外部パートナー企業へ安全に公開鍵(JWKS等で配信)を共有できます。
  • デメリット:
  • 計算コストが HS256 に比べて高く、CPUに負荷がかかる(特にRSAの署名検証はCPUバウンドな処理です)。
  • トークンのサイズが大きくなる(鍵長が2048bit以上推奨のため、署名自体が長くなる)。

—

3. 通信フローとAPIゲートウェイにおける処理の現実

では、実戦的なマイクロサービスアーキテクチャにおいて、この違いがどのように影響するでしょうか。典型的ないわゆるOAuth 2.0 / OIDCのフローを考えてみます。

[Client] ---> (1. 認証リクエスト) ---> [Auth Server (IdP)]
                                           |
[Client] <--- (2. JWT返却) <---------------+
   |
   +---> (3. APIリクエスト + Authorization: Bearer JWT) ---> [API Gateway / Envoy]
                                                                    |
                                                      (4. 署名検証: CPU負荷の発生)
                                                                    |
                                                              [Backend Microservices]

ここでボトルネックになりやすいのが、ステップ4(APIゲートウェイでの署名検証)です。

毎秒数千〜数万件のトラフィック(RPS)をさばくAPIゲートウェイにおいて、すべてのリクエストでJWTの署名検証が行われます。

  • HS256 の場合: 共通鍵を用いたハッシュ計算のみなので、CPU負荷は極めて軽微です。
  • RS256 の場合: RSAの公開鍵暗号の数学的検証(モジュラー累乗計算など)が入るため、CPUサイクルをかなり消費します。大規模トラフィック環境では、ここがスループットの頭打ち(CPUネック)になることがあります。

このため、インフラアーキテクトとしては「セキュアだから全てRS256にすればいい」という短絡的な思考は危険であり、システムのトポロジーに応じた選択が求められます。

—

4. 現場で役立つ選択基準:どちらを選ぶべきか?

実際のプロジェクトでどちらを採用すべきか、以下のマトリクスを判断基準にしてください。

| 評価項目 | HS256 (対称鍵) | RS256 (非対称鍵) |
| :— | :— | :— |
| システム規模 | 単一のモノリス、または同一ドメイン内の閉じられたマイクロサービス | マルチテナント、外部公開API、多数の独立したマイクロサービス間連携 |
| 運用の複雑さ | 秘密鍵の安全な配布・ローテーションの仕組みが必要 | 公開鍵のエンドポイント(/.well-known/jwks.json)を公開すればよく、検証側は秘密を持たなくてよい |
| パフォーマンス | 極めて高速(CPU負荷小) | 若干のオーバーヘッドあり(キャッシングやJWKSの適切な管理が必須) |
| 推奨ユースケース | 自社開発の閉じたWebアプリとAPIサーバーのセット | Auth0やCognitoなどの外部IdPを利用する場合、またはマイクロサービスのゼロトラストアーキテクチャ |

実務的な結論として、認証基盤(IdP)とAPIサーバーが完全に一体、あるいは同一チームの管理下にあるクローズドなシステムであれば HS256 でも十分に機能します。
しかし、APIを外部公開する場合、複数の異なる組織やチームがAPIを開発する場合、あるいはOAuth 2.0の標準的なIdP(Auth0, Azure AD, Cognito等)を利用する場合は、事実上の業界標準として RS256 を選択するのが無難です。

—

5. 実装例:Pythonと各種ライブラリによる検証コード

それでは、実務で遭遇する設定やコードの具体例を見ていきましょう。ここでは、Pythonの PyJWT ライブラリを用いて、それぞれのアルゴリズムでトークンを発行・検証するコードを示します。

パターンA: HS256 の実装例

共通鍵(シークレット)を両者で共有するシンプルなパターンです。

import jwt
from datetime import datetime, timedelta

# 共有する秘密鍵(本番環境では環境変数やセキュアストアから読み込むこと)
SECRET_KEY = "super-secret-key-that-should-be-very-long-and-random"

# 1. トークンの発行 (認証サーバー側の処理)
def create_hs256_token(user_id: str):
    payload = {
        "sub": user_id,
        "name": "Yamada Taro",
        "iat": datetime.utcnow(),
        "exp": datetime.utcnow() + timedelta(hours=1) # 1時間で失効
    }
    # HS256で署名してトークンを生成
    token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
    return token

# 2. トークンの検証 (APIサーバー側の処理)
def verify_hs256_token(token: str):
    try:
        # 同じ秘密鍵を使って署名検証とデコードを同時に行う
        decoded_payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
        return True, decoded_payload
    except jwt.ExpiredSignatureError:
        return False, "Token has expired"
    except jwt.InvalidTokenError:
        return False, "Invalid token"

# 実行テスト
if __name__ == "__main__":
    token = create_hs256_token("user_12345")
    print(f"Generated HS256 Token:\n{token}\n")
    
    success, result = verify_hs256_token(token)
    print(f"Verification Result: {success}, Payload: {result}")

パターンB: RS256 の実装例

秘密鍵で署名し、公開鍵で検証する非対称なパターンです。実務では、検証側(APIサーバー)は公開鍵のみを保持します。

import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from datetime import datetime, timedelta

# --- 準備: 鍵ペアの生成(通常は事前生成され、IdPとAPI間で安全に管理される) ---
private_key = rsa.generate_private_key(
    public_exponent=65537,
    key_size=2048
)
public_key = private_key.public_key()

# 秘密鍵をPEM形式に変換(認証局が保持)
pem_private_key = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.NoEncryption()
)

# 公開鍵をPEM形式に変換(APIゲートウェイや検証側が保持)
pem_public_key = public_key.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo
)

# 1. トークンの発行 (認証サーバー側:秘密鍵を使用)
def create_rs256_token(user_id: str):
    payload = {
        "sub": user_id,
        "role": "admin",
        "iat": datetime.utcnow(),
        "exp": datetime.utcnow() + timedelta(hours=1)
    }
    # RS256と秘密鍵を指定して署名
    token = jwt.encode(payload, pem_private_key, algorithm="RS256")
    return token

# 2. トークンの検証 (APIサーバー側:公開鍵のみを使用)
def verify_rs256_token(token: str):
    try:
        # 公開鍵のみで署名を検証(秘密鍵はここには存在しない!)
        decoded_payload = jwt.decode(token, pem_public_key, algorithms=["RS256"])
        return True, decoded_payload
    except jwt.ExpiredSignatureError:
        return False, "Token has expired"
    except jwt.InvalidTokenError:
        return False, "Invalid signature or malformed token"

# 実行テスト
if __name__ == "__main__":
    token = create_rs256_token("admin_999")
    print(f"Generated RS256 Token:\n{token}\n")
    
    success, result = verify_rs256_token(token)
    print(f"Verification Result: {success}, Payload: {result}")

—

6. 現場のトラブルシューティングと運用上の極意

最後に、インフラ現場でエンジニアがハマりがちな「JWT署名にまつわる罠」と、その対策をいくつか共有しておきます。

1. 「alg: none」脆弱性に気をつけろ

古いJWTライブラリや不適切な実装では、攻撃者がヘッダーのアルゴリズムを "alg": "none" に書き換えた改ざんトークンを送り込んできた際、署名の検証をスキップして通してしまうという深刻な脆弱性(CVE-2015-9235など)が存在しました。

  • 対策: ライブラリを使用する際は必ず algorithms=["RS256"] のように、許可するアルゴリズムをホワイトリスト形式で明示的に指定してください。決してライブラリのデフォルト任せにしてはいけません。

2. RS256における公開鍵のキャッシュとJWKSローテーション

毎回ディスクやリモートの /.well-known/jwks.json から公開鍵をフェッチしていると、ネットワークI/Oの遅延や、IdP障害時のAPI停止(カスケード障害)を引き起こします。

  • 対策: APIゲートウェイ(Nginx, Envoy, Kongなど)やアプリケーション層で、JWKSのレスポンスを適切にメモリ上にキャッシュ(数時間〜1日程度)しつつ、鍵のローテーション(Key Rollover)時に新しいkid(Key ID)を正しくハンドリングできるように設計しましょう。

3. クロック・スキュー(Clock Skew)への配慮

分散システムにおいて、認証サーバーとAPIサーバーのシステム時計がわずかにズレているだけで、exp(有効期限)や nbf(有効化時刻)のバリデーションエラーが多発します。

  • 対策: 検証を行うライブラリの設定で、数秒〜数十秒程度の許容誤差(leeway パラメータ)を設けるのが、現場を平和に保つためのちょっとしたテクニックです。

—

まとめ

JWTの HS256 と RS256 の選択は、単なる暗号理論の比較ではありません。

  • 閉じられたシンプルなシステムで、パフォーマンスを最優先させたいなら HS256。
  • ゼロ・トラストなセキュリティ境界を引きたい、あるいは外部サービスやマイクロサービス間で安全に認証を委譲したいなら RS256。

それぞれのメリット・デメリット、そしてインフラストラクチャに与える影響を正しく見極め、ご自身のシステムの規模やトポロジーに最適な設計を選択してください。

それでは、また次回の深淵なるプロトコルの世界でお会いしましょう。

コメント

タイトルとURLをコピーしました