PyJWT徹底解説:PythonでのJWT実装ガイド

Web開発

JSON Web Token (JWT) の基本からPyJWTライブラリの使い方、セキュリティまで

はじめに:JWTとは何か?🤔

Web開発の世界では、認証や情報の安全なやり取りが非常に重要です。その中で注目されている技術の一つがJSON Web Token (JWT)、通称「ジョット」です。

JWTは、情報をコンパクトかつ自己完結した形で表現するためのオープンスタンダード (RFC 7519) です。JSON形式のデータを使い、デジタル署名によって改ざんされていないことを保証したり、暗号化して内容を秘匿したりできます。URLセーフな文字列として表現できるため、HTTPヘッダーやクエリパラメータでの送信に適しています。

主に、以下のような目的で利用されます。

  • 認証 (Authentication): ユーザーがログインに成功した後、サーバーはそのユーザーを識別するためのJWTを発行します。以降のリクエストでは、クライアントはこのJWTを送信することで、自身が認証済みであることを証明します。サーバーはセッション情報を保持する必要がなくなり、ステートレスな認証が可能になります。
  • 情報交換 (Information Exchange): JWTは、二者間で情報を安全に送信するための手段としても利用できます。署名によって情報の送信者を確認でき、改ざんされていないことを保証できます。

JWTの構造:3つのパート

JWTは、ピリオド (.) で区切られた3つの部分から構成されます。

ヘッダー.ペイロード.シグネチャ
  1. ヘッダー (Header):
    • トークンのタイプ (通常は “JWT”) と、署名に使用されるアルゴリズム (例: HS256, RS256) を示すJSONオブジェクトです。
    • このJSONはBase64Urlエンコードされて、JWTの最初の部分になります。
    • 例: {"alg": "HS256", "typ": "JWT"}
  2. ペイロード (Payload):
    • クレーム (Claim) と呼ばれる、伝達したい情報を含むJSONオブジェクトです。クレームは、ユーザーID、名前、権限などのエンティティに関する情報や、追加のメタデータを含みます。
    • クレームには、予約済みクレーム (Registered Claims)、公開クレーム (Public Claims)、プライベートクレーム (Private Claims) の3種類があります。
    • このJSONもBase64Urlエンコードされて、JWTの2番目の部分になります。
    • 例: {"sub": "1234567890", "name": "John Doe", "iat": 1516239022}
    • 注意: ペイロードはエンコードされているだけで、暗号化されているわけではありません(JWEを除く)。そのため、機密性の高い情報をペイロードに含めるべきではありません。 Base64Urlは簡単にデコードできます。
  3. シグネチャ (Signature):
    • エンコードされたヘッダー、エンコードされたペイロード、ヘッダーで指定されたアルゴリズム、そして秘密鍵(対称アルゴリズムの場合)または秘密鍵/公開鍵ペア(非対称アルゴリズムの場合)を使用して生成されます。
    • この署名は、トークンが改ざんされていないこと、そして(対称アルゴリズムの場合)送信者が秘密鍵を知っていること、または(非対称アルゴリズムの場合)秘密鍵で署名されたことを検証するために使用されます。
    • 例: HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

PyJWTライブラリの紹介 ✨

PyJWT は、PythonでJWTのエンコード(生成)とデコード(検証)を行うためのライブラリです。RFC 7519 に準拠しており、Pythonアプリケーションにトークンベースの認証などを簡単に追加できます。

現在の安定版は 2.10.1 (2024年11月27日リリース時点の情報) で、Python 3.9以上をサポートしています。

インストール方法

PyJWTはpipを使って簡単にインストールできます。

pip install PyJWT

特定の暗号アルゴリズム(特にRSAやECDSAなどの公開鍵暗号)を使用する場合は、追加の暗号ライブラリが必要になることがあります。PyJWTは `cryptography` ライブラリに依存しており、通常はPyJWTと一緒に自動でインストールされます。もし手動でインストールする場合は、以下のようにします。

pip install cryptography

PyJWTと暗号化関連の依存関係をまとめてインストールするには、`crypto` エクストラを使用できます。

pip install PyJWT[crypto]

基本的な使い方 🛠️

JWTのエンコード (生成)

`jwt.encode()` 関数を使用して、ペイロードデータからJWTを生成します。最低限必要なのは、ペイロード (辞書型)、秘密鍵 (文字列またはバイト列)、そしてアルゴリズムです。

import jwt
import datetime

# ペイロードデータ (含めたい情報)
payload = {
    "user_id": 123,
    "username": "taro_yamada",
    "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1), # 有効期限 (1時間後)
    "iat": datetime.datetime.now(datetime.timezone.utc), # 発行日時
    "iss": "my_auth_server", # 発行者
    "aud": "my_resource_server" # 受信者
}

# 秘密鍵 (実際にはもっと複雑で安全なものを使用)
secret_key = "your-super-secret-key"

# HS256アルゴリズムでJWTを生成
encoded_jwt = jwt.encode(payload, secret_key, algorithm="HS256")

print(f"生成されたJWT: {encoded_jwt}")

上記の例では、HS256 (HMAC-SHA256) という対称鍵アルゴリズムを使用しています。これは、エンコードとデコードに同じ秘密鍵を使用するアルゴリズムです。

JWTのデコード (検証)

`jwt.decode()` 関数を使用して、受け取ったJWTを検証し、ペイロードを取得します。検証プロセスでは、署名の正当性、有効期限、発行者 (iss)、受信者 (aud) などがチェックされます。

import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError, InvalidAudienceError, InvalidIssuerError

# 受け取ったJWT (上記のエンコード例で生成したもの)
# encoded_jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoidGFyb195YW1hZGEiLCJleHAiOjE2Nzk4NTAwMDAsImlhdCI6MTY3OTg0NjQwMCwiaXNzIjoibXlfYXV0aF9zZXJ2ZXIiLCJhdWQiOiJteV9yZXNvdXJjZV9zZXJ2ZXIifQ.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # 実際のトークンに置き換えてください

# エンコード時と同じ秘密鍵
secret_key = "your-super-secret-key"

# 検証したい発行者と受信者
valid_issuer = "my_auth_server"
valid_audience = "my_resource_server"

try:
    # HS256アルゴリズムでJWTをデコード・検証
    # algorithms引数はリストで指定する必要がある
    # audienceとissuerを指定して検証を強化
    decoded_payload = jwt.decode(
        encoded_jwt,
        secret_key,
        algorithms=["HS256"], # 検証を許可するアルゴリズムのリスト
        audience=valid_audience,
        issuer=valid_issuer
    )

    print("デコード成功!")
    print(f"ペイロード: {decoded_payload}")

except ExpiredSignatureError:
    print("トークンの有効期限が切れています。")
except InvalidAudienceError:
    print("トークンの受信者 (audience) が無効です。")
except InvalidIssuerError:
    print("トークンの発行者 (issuer) が無効です。")
except InvalidTokenError as e:
    # その他のJWT関連エラー (署名不正、フォーマット不正など)
    print(f"無効なトークンです: {e}")
except Exception as e:
    print(f"予期せぬエラーが発生しました: {e}")

重要: `jwt.decode()` を使用する際には、必ず `algorithms` 引数を指定してください。これにより、意図しないアルゴリズム(例えば `none` アルゴリズム)の使用を強制される「アルゴリズム混乱攻撃」を防ぐことができます。PyJWTの最近のバージョンでは `algorithms` の指定が必須になっています。

主要な概念 🔑

クレーム (Claims)

ペイロードに含まれる情報の各項目をクレームと呼びます。以下の種類があります。

  • 予約済みクレーム (Registered Claims): IANAによって定義済みのクレーム。必須ではありませんが、利用が推奨されます。相互運用性のために役立ちます。
    • iss (Issuer): トークンの発行者
    • sub (Subject): トークンの主題 (通常はユーザーID)
    • aud (Audience): トークンの受信者 (対象とするサービスやリソース)
    • exp (Expiration Time): トークンの有効期限 (Unix Time)
    • nbf (Not Before): トークンが有効になる日時 (Unix Time)
    • iat (Issued At): トークンが発行された日時 (Unix Time)
    • jti (JWT ID): トークンの一意な識別子 (リプレイ攻撃対策などに利用)
  • 公開クレーム (Public Claims): JWTを使用する当事者間で自由に定義できるクレーム。衝突を避けるために、IANA JSON Web Token Claimsレジストリに登録するか、衝突耐性のある名前空間 (URIなど) を使用することが推奨されます。
  • プライベートクレーム (Private Claims): トークンの発行者と受信者の間で合意された、任意の情報を含むクレーム。名前の衝突に注意が必要です。

PyJWTでは、`decode` 時の `options` 引数で特定のクレームの存在を必須にしたり (`require=[“exp”, “iss”]`)、有効期限 (`exp`) や発行日時 (`iat`) の検証を無効化したり (`verify_exp=False`, `verify_iat=False`) することが可能です。

import jwt
import time

# requireオプションで特定のクレームを必須にする
options_require = {
    "require": ["exp", "iat", "iss", "aud", "user_id"]
}

# verify_expオプションで有効期限の検証をスキップ (非推奨)
options_skip_exp = {
    "verify_exp": False
}

try:
    # 例: 必須クレームを指定してデコード
    decoded_payload_req = jwt.decode(
        encoded_jwt,
        secret_key,
        algorithms=["HS256"],
        options=options_require
    )
    print("必須クレーム検証成功!")

    # 例: 有効期限切れトークンでも検証をスキップしてデコード (注意して使用)
    expired_token = jwt.encode({"user_id": 456, "exp": int(time.time()) - 3600}, secret_key, algorithm="HS256")
    decoded_payload_skip = jwt.decode(
        expired_token,
        secret_key,
        algorithms=["HS256"],
        options=options_skip_exp # 有効期限切れでもエラーにならない
    )
    print(f"有効期限検証スキップ成功! ペイロード: {decoded_payload_skip}")

except jwt.MissingRequiredClaimError as e:
    print(f"必須クレームがありません: {e}")
except jwt.ExpiredSignatureError:
    print("トークンの有効期限が切れています。(skip_exp未使用時)")
except Exception as e:
    print(f"エラー: {e}")

アルゴリズム (Algorithms)

JWTの署名には様々なアルゴリズムが使用できます。PyJWTは多数のアルゴリズムをサポートしています。

アルゴリズム 説明 鍵の種類 備考
HS256 HMAC using SHA-256 対称鍵 (共有秘密鍵) デフォルト。シンプルだが鍵の共有が必要。
HS384 HMAC using SHA-384 対称鍵 (共有秘密鍵)
HS512 HMAC using SHA-512 対称鍵 (共有秘密鍵)
RS256 RSASSA-PKCS1-v1_5 using SHA-256 非対称鍵 (RSA秘密鍵/公開鍵) 広く使われている公開鍵暗号。署名に秘密鍵、検証に公開鍵を使用。
RS384 RSASSA-PKCS1-v1_5 using SHA-384 非対称鍵 (RSA秘密鍵/公開鍵)
RS512 RSASSA-PKCS1-v1_5 using SHA-512 非対称鍵 (RSA秘密鍵/公開鍵)
ES256 ECDSA using P-256 and SHA-256 非対称鍵 (EC秘密鍵/公開鍵) 楕円曲線暗号。RSAより短い鍵長で同等の強度。
ES384 ECDSA using P-384 and SHA-384 非対称鍵 (EC秘密鍵/公開鍵)
ES512 ECDSA using P-521 and SHA-512 非対称鍵 (EC秘密鍵/公開鍵)
ES256K ECDSA using secp256k1 and SHA-256 非対称鍵 (EC秘密鍵/公開鍵) Bitcoinなどで使用される曲線。
PS256 RSASSA-PSS using SHA-256 and MGF1 with SHA-256 非対称鍵 (RSA秘密鍵/公開鍵) より安全とされるRSA署名方式。
PS384 RSASSA-PSS using SHA-384 and MGF1 with SHA-384 非対称鍵 (RSA秘密鍵/公開鍵)
PS512 RSASSA-PSS using SHA-512 and MGF1 with SHA-512 非対称鍵 (RSA秘密鍵/公開鍵)
EdDSA Ed25519 / Ed448 非対称鍵 (EdDSA秘密鍵/公開鍵) 比較的新しい高効率な署名アルゴリズム。PyJWTはEd25519とEd448をサポート。
none 署名なし 不要 非推奨: セキュリティリスクがあるため、通常は使用しません。PyJWT 2.10.0以降では、`encode`/`decode` で `algorithm=”none”` を明示的に指定する必要があります。

アルゴリズムの選択はセキュリティ要件に依存します。一般的に、公開鍵暗号 (RS*, ES*, PS*, EdDSA) は、署名者と検証者が異なる場合に適しています(例: 認証サーバーとリソースサーバー)。HMAC (HS*) は、単一のアプリケーション内など、秘密鍵を安全に共有できる場合に適しています。

発展的な使い方 🚀

公開鍵暗号 (RSA, ECDSAなど) の利用

RS256などの非対称アルゴリズムを使用する場合、エンコードには秘密鍵、デコード(検証)には対応する公開鍵を使用します。鍵は通常PEM形式で表現されます。

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

# --- 鍵の生成 (通常は事前に生成しておく) ---
# 秘密鍵を生成
private_key = rsa.generate_private_key(
    public_exponent=65537,
    key_size=2048
)
# 公開鍵を取得
public_key = private_key.public_key()

# 秘密鍵をPEM形式 (PKCS8) でシリアライズ
private_pem = private_key.private_bytes(
   encoding=serialization.Encoding.PEM,
   format=serialization.PrivateFormat.PKCS8,
   encryption_algorithm=serialization.NoEncryption() # パスワードなし
)

# 公開鍵をPEM形式 (SubjectPublicKeyInfo) でシリアライズ
public_pem = public_key.public_bytes(
   encoding=serialization.Encoding.PEM,
   format=serialization.PublicFormat.SubjectPublicKeyInfo
)

# print("--- Private Key (PEM) ---")
# print(private_pem.decode('utf-8'))
# print("--- Public Key (PEM) ---")
# print(public_pem.decode('utf-8'))

# --- JWTのエンコード (秘密鍵を使用) ---
payload_rsa = {
    "user_id": 789,
    "role": "admin",
    "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=30)
}

encoded_jwt_rsa = jwt.encode(payload_rsa, private_pem, algorithm="RS256")
print(f"RS256でエンコードされたJWT: {encoded_jwt_rsa}")

# --- JWTのデコード (公開鍵を使用) ---
try:
    decoded_payload_rsa = jwt.decode(
        encoded_jwt_rsa,
        public_pem, # 検証には公開鍵を使用
        algorithms=["RS256"] # 検証を許可するアルゴリズム
    )
    print("RS256デコード成功!")
    print(f"ペイロード: {decoded_payload_rsa}")

except jwt.InvalidSignatureError:
    print("署名が無効です。公開鍵が間違っているか、トークンが改ざんされています。")
except jwt.ExpiredSignatureError:
    print("トークンの有効期限が切れています。")
except Exception as e:
    print(f"エラー: {e}")

ECDSA (ES256など) や EdDSA も同様に、エンコードに秘密鍵、デコードに公開鍵を使用します。

ヘッダーのカスタマイズ

`jwt.encode()` の `headers` 引数を使うと、JWTヘッダーにカスタムフィールドを追加できます。例えば、鍵の識別子 (Key ID: `kid`) を含めることができます。これは、複数の鍵をローテーションする場合などに便利です。

import jwt

payload_custom_header = {"data": "important"}
secret = "another-secret"
custom_headers = {"kid": "key_id_001"}

encoded_with_header = jwt.encode(
    payload_custom_header,
    secret,
    algorithm="HS256",
    headers=custom_headers
)

print(f"カスタムヘッダー付きJWT: {encoded_with_header}")

# ヘッダーだけを検証せずに読み取る (デバッグ用)
try:
    unverified_headers = jwt.get_unverified_header(encoded_with_header)
    print(f"検証前のヘッダー: {unverified_headers}")
except jwt.InvalidTokenError as e:
    print(f"ヘッダー読み取りエラー: {e}")

# デコード時にもヘッダーは含まれる
try:
    # jwt.decode_complete を使うとヘッダー、ペイロード、署名などを個別に取得できる
    decoded_complete = jwt.decode_complete(
        encoded_with_header,
        secret,
        algorithms=["HS256"]
    )
    print(f"完全デコード結果 - ヘッダー: {decoded_complete['header']}")
    print(f"完全デコード結果 - ペイロード: {decoded_complete['payload']}")

except Exception as e:
    print(f"デコードエラー: {e}")

有効期限の猶予 (Leeway)

サーバー間でクロック同期にわずかなずれがある場合、有効期限 (`exp`) や発行日時 (`iat`)、有効開始日時 (`nbf`) の検証で問題が発生することがあります。`jwt.decode()` の `leeway` オプションで、数秒程度の許容範囲を設定できます。

import jwt
import time
import datetime

secret = "leeway-secret"
leeway_seconds = 5 # 5秒の猶予

# 1秒前に期限切れしたトークンを作成
payload_slightly_expired = {
    "user_id": 101,
    "exp": datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(seconds=1)
}
token_slightly_expired = jwt.encode(payload_slightly_expired, secret, algorithm="HS256")

# leewayなしだとエラーになる
try:
    jwt.decode(token_slightly_expired, secret, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
    print("Leewayなし: 予想通り有効期限切れエラー")

# leewayありだと成功する
try:
    decoded_with_leeway = jwt.decode(
        token_slightly_expired,
        secret,
        algorithms=["HS256"],
        leeway=leeway_seconds # 5秒のずれを許容
    )
    print(f"Leewayあり: デコード成功! ペイロード: {decoded_with_leeway}")
except jwt.ExpiredSignatureError:
    print("Leewayあり: エラー (予期せぬ動作)")
except Exception as e:
    print(f"Leewayあり: その他のエラー: {e}")

検証なしでのペイロード/ヘッダー読み取り

デバッグ目的などで、署名の検証を行わずにペイロードやヘッダーの内容を確認したい場合があります。ただし、これは本番環境での利用には適していません。

  • jwt.get_unverified_header(token): ヘッダーを検証せずに取得します。
  • jwt.decode(token, options={"verify_signature": False}): 署名検証をスキップしてペイロードを取得します(非推奨)。

警告: 署名検証をスキップすることは、トークンが改ざんされている可能性を無視することになります。セキュリティ上の理由から、本番コードでは絶対に使用しないでください。

セキュリティに関する考慮事項 🔒

JWTは便利ですが、正しく使用しないとセキュリティリスクが生じます。PyJWTを使用する際は以下の点に注意してください。

  1. 強力なアルゴリズムの使用: 可能であれば、HS256のような対称鍵アルゴリズムよりも、RS256やES256のような非対称鍵アルゴリズムを使用することを検討してください。特に、トークン発行者と検証者が異なるシステムの場合に適しています。HS*アルゴリズムを使用する場合は、十分に長く、推測困難な秘密鍵を使用してください。ブルートフォース攻撃のリスクがあります。
  2. `algorithms` の明示的な指定: `jwt.decode()` 時に、許可するアルゴリズムを `algorithms` 引数で必ず指定してください。これにより、攻撃者がヘッダーの `alg` を書き換えて署名検証をバイパスする「アルゴリズム混乱攻撃」を防ぎます。例えば、本来RS256で検証すべきところを、攻撃者が `alg` を HS256 に書き換え、公開鍵をHS256の秘密鍵として使わせるような攻撃です。PyJWT 2.4.0以降では、`get_default_algorithms()` はデフォルトでは安全でないアルゴリズム (`none`など) を含まなくなりましたが、明示的な指定がベストプラクティスです。
  3. `alg: none` のリスク: `none` アルゴリズムは署名を行わないため、本番環境での使用は避けるべきです。PyJWT 2.10.0以降では、`none` を使う場合、`encode`/`decode` 時に `algorithm=”none”` / `algorithms=[“none”]` と明示的に指定する必要があります。
  4. ペイロードに機密情報を含めない: JWTのペイロードはBase64Urlエンコードされているだけで、暗号化されていません(JWEでない限り)。パスワード、APIキー、個人情報などの機密データをペイロードに含めないでください。
  5. 有効期限 (`exp`) の設定: 必ず `exp` クレームを設定し、その値を適切に短く設定してください(例: 数分~数時間)。トークンが漏洩した場合の影響範囲を限定できます。リフレッシュトークンと組み合わせるのが一般的です。
  6. 発行者 (`iss`) と受信者 (`aud`) の検証: `jwt.decode()` 時に `issuer` と `audience` を指定して、トークンが信頼できる発行元から来ており、意図した受信者向けのものであることを確認してください。
  7. HTTPSの使用: JWTをHTTPヘッダーなどで送信する際は、必ずHTTPSを使用して通信経路を暗号化してください。これにより、中間者攻撃によるトークンの盗聴を防ぎます。
  8. トークンの安全な保管: クライアント側でJWTを保管する場合、`localStorage` はクロスサイトスクリプティング (XSS) 攻撃に対して脆弱なため、可能であれば `HttpOnly` 属性と `Secure` 属性を付けたCookieを使用することを検討してください。
  9. 鍵管理: 秘密鍵や共有秘密鍵は厳重に管理し、定期的にローテーションすることを検討してください。公開鍵暗号を使用する場合、公開鍵の配布方法 (例: JWKSエンドポイント) も安全に行う必要があります。
  10. ライブラリのアップデート: PyJWTや依存ライブラリに脆弱性が発見されることがあります。定期的に最新バージョンにアップデートしてください。過去には、アルゴリズム混乱に関連する脆弱性 (CVE-2022-29217など) が報告されています。バージョン2.4.0で対策済みです。
  11. トークン失効: JWTはステートレスであるため、一度発行すると有効期限が切れるまで有効です。ユーザーのログアウトや権限変更時に即座にトークンを無効化したい場合は、別途トークン失効リスト(ブラックリスト)をサーバー側で管理するなどの追加実装が必要になります。

まとめ 🎉

PyJWTは、PythonでJWTを扱うための強力で使いやすいライブラリです。JWTの基本的な仕組みを理解し、PyJWTのエンコード・デコード機能、そしてセキュリティ上の注意点を把握することで、安全で信頼性の高い認証システムや情報交換メカニズムを構築できます。

重要なのは、常にセキュリティを意識し、アルゴリズムの選択、クレームの検証、鍵管理、ライブラリのアップデートなどを適切に行うことです。この記事が、PyJWTを使った開発の一助となれば幸いです。😊

より詳細な情報や最新のアップデートについては、PyJWTの公式ドキュメント を参照してください。

コメント

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