python jwt 署名 検証 実装の知識は、FastAPIやFlaskで認証を作るエンジニアにとって今や必須スキルです。JWTを「なんとなく動かしている」状態は、alg:none攻撃のような致命的な脆弱性を見逃すリスクがあります。

この記事では、PyJWTを使ってJWT署名・検証・改ざん検知・有効期限管理まで、実際に動くコードを手を動かしながら完全に理解できるよう解説します。セキュリティ的にやってはいけない実装をあえて示してから正しい実装に誘導する構成なので、自分のコードが安全かどうかをチェックしながら読み進めてください。

この記事で分かること

  • JWTの内部構造をPython標準ライブラリだけでbase64デコードして解剖する方法
  • PyJWT 2.x系でHS256/RS256の署名・検証・改ざん検知を実装する手順
  • alg:none攻撃・アルゴリズム混同攻撃の実演と安全な防御コードの書き方(CVE-2026-28802解説付き)

JWTとは何か:構造をPythonで解剖する

JWT(JSON Web Token)とは、JSON形式の情報を安全に送受信するためのトークン形式です。サーバーがセッション情報を持たなくてもよい「ステートレス認証」を実現できることが最大の特徴で、マイクロサービス間の認証や、スケールアウトが必要なWebアプリの認証基盤として広く採用されています。

JWTの3部構成を図で整理するとこうなります。

部位内容エンコード
ヘッダーアルゴリズム種別(alg)、トークン種別(typ)Base64URL
ペイロードユーザーID・有効期限などのクレーム情報Base64URL
署名ヘッダー+ペイロードをアルゴリズムで署名した値Base64URL

Base64デコードして中身を見る

PyJWTを使わなくても、JWTの中身はPython標準ライブラリだけで確認できます。「JWTは暗号化されている」と誤解している方が多いですが、ペイロードは単にBase64URLエンコードされているだけで、誰でも読めます。機密情報をペイロードに入れてはいけない理由がここにあります。

import base64
import json

# サンプルJWTトークン(実際に存在する形式)
token = (
    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
    ".eyJzdWIiOiJ1c2VyXzEyMyIsImlzcyI6Im15YXBwIiwiZXhwIjoxNzUwMDAwMDAwLCJpYXQiOjE3NDk5MDAwMDB9"
    ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
)

def decode_jwt_without_verify(token: str) -> dict:
    """署名検証なしでJWTのヘッダーとペイロードをデコードする(確認用のみ)"""
    parts = token.split(".")
    if len(parts) != 3:
        raise ValueError("JWTの形式が不正です(3パートである必要があります)")

    results = {}
    for i, label in enumerate(["header", "payload"]):
        part = parts[i]
        # Base64URLのパディング調整(=を補完)
        padding = 4 - len(part) % 4
        if padding != 4:
            part += "=" * padding
        try:
            decoded = base64.urlsafe_b64decode(part).decode("utf-8")
            results[label] = json.loads(decoded)
        except Exception as e:
            raise ValueError(f"{label}のデコードに失敗しました: {e}")

    return results

try:
    result = decode_jwt_without_verify(token)
    print("=== Header ===")
    print(json.dumps(result["header"], indent=2, ensure_ascii=False))
    print("\n=== Payload ===")
    print(json.dumps(result["payload"], indent=2, ensure_ascii=False))
except ValueError as e:
    print(f"エラー: {e}")

なぜJWTはセッションより優れているのか

従来のセッション認証では、サーバー側がセッションIDとユーザー情報の対応表をメモリやDBに保持し続ける必要があります。一方JWTは、トークン自体に必要な情報と署名が含まれているため、サーバーはDBを参照せず署名を検証するだけで認証完了できます。複数サーバーへのスケールアウト時にセッション共有の仕組みが不要になり、マイクロサービスアーキテクチャとの相性が抜群です。

PyJWTのインストールとHS256署名・検証

HS256(HMAC-SHA256)とは、共有秘密鍵(同じ鍵)で署名と検証の両方を行う対称鍵アルゴリズムです。シンプルで高速ですが、検証側にも秘密鍵を渡す必要があります。

手順1:PyJWTのインストール

RS256など非対称鍵アルゴリズムも使いたい場合は、cryptographyライブラリも一緒にインストールします。最初からこちらを入れておくと後々便利です。

# PyJWT本体とcryptographyオプションを同時にインストール
pip install "PyJWT[cryptography]"

手順2:HS256で署名・検証する最小コード

NG実装から見せます。よく見かける危険なコードがこれです。algorithmsパラメータを省略すると、後述するalg:none攻撃に対して無防備になります。

import jwt  # PyJWT 2.x系

SECRET_KEY = "my-secret-key"
token = jwt.encode({"sub": "user_123"}, SECRET_KEY, algorithm="HS256")

# ❌ 危険:algorithmsを指定しない(PyJWT 2.xではエラーになるが1.x系では通過する)
# decoded = jwt.decode(token, SECRET_KEY)  # 絶対にやってはいけない

次が正しい実装です。algorithmsに許可するアルゴリズムのリストを明示的に渡します。

import jwt
import datetime

# PyJWT 2.x系(1.x系との差異:encode()の戻り値がbytesではなくstr)
SECRET_KEY = "your-very-secret-key-change-this-in-production"

def create_hs256_token(user_id: str) -> str:
    """HS256でJWTを生成する"""
    now = datetime.datetime.now(tz=datetime.timezone.utc)
    payload = {
        "iss": "myapp",          # issuer: トークン発行者
        "sub": user_id,          # subject: トークンの主体(ユーザーID)
        "iat": now,              # issued at: 発行時刻
        "exp": now + datetime.timedelta(minutes=30),  # expiration: 有効期限
    }
    # algorithm(単数形)で署名アルゴリズムを指定
    token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
    return token

def verify_hs256_token(token: str) -> dict:
    """HS256でJWTを検証する"""
    try:
        # ✅ 正しい実装:algorithms(複数形・リスト)で許可アルゴリズムを明示
        decoded = jwt.decode(
            token,
            SECRET_KEY,
            algorithms=["HS256"],  # ここを絶対に省略しないこと
        )
        return decoded
    except jwt.ExpiredSignatureError:
        raise ValueError("トークンの有効期限が切れています")
    except jwt.InvalidTokenError as e:
        raise ValueError(f"トークンが無効です: {e}")

# 動作確認
token = create_hs256_token("user_123")
print(f"生成トークン: {token[:50]}...")

decoded = verify_hs256_token(token)
print(f"検証成功 - ユーザーID: {decoded['sub']}")

⚠️ PyJWT 1.x系との互換性注意: 1.x系では jwt.encode() がbytes型を返すため、.decode('utf-8') が必要でした。2.x系ではstr型が返るのでその処理は不要です。また、jwt.decode() の algorithms パラメータが2.x系では必須になっています。

標準クレームの意味と使い方

ペイロードに含める標準クレーム(RFC 7519で定義)を整理しておきます。

クレーム意味推奨設定
issIssuer(発行者)サービス名や発行元URL
subSubject(主体)ユーザーIDや識別子
expExpiration Time(有効期限)必ず設定する(UNIXタイムスタンプ)
iatIssued At(発行時刻)設定推奨
jtiJWT ID(一意識別子)リプレイ攻撃対策に有効

RS256(非対称鍵)での署名・検証

RS256(RSA-SHA256)とは、秘密鍵で署名・公開鍵で検証を行う非対称鍵アルゴリズムです。公開鍵は誰にでも渡せるため、複数のサービスがトークンを検証できる構成(OAuth 2.0, OpenID Connect)に適しています。

RSA鍵ペアの生成

まず署名に使う鍵ペアを生成します。本番環境では事前にファイルで管理しますが、ここではコード内でオンザフライ生成して動作を確認します。

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

def generate_rsa_keypair() -> tuple[bytes, bytes]:
    """RSA鍵ペアを生成してPEM形式で返す"""
    # 2048bit以上を推奨(本番は4096bitも選択肢)
    private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048,
    )

    # 秘密鍵をPEM形式でシリアライズ
    private_pem = private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.TraditionalOpenSSL,
        encryption_algorithm=serialization.NoEncryption(),  # 本番では暗号化推奨
    )

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

    return private_pem, public_pem

private_pem, public_pem = generate_rsa_keypair()
print("✅ RSA鍵ペア生成完了")
print(private_pem.decode()[:64], "...")

RS256で署名・検証する実装コード

鍵ペアが用意できたら、PyJWTに渡すだけです。HS256とAPIの形は同じで、algorithmの指定が変わるだけです。

import jwt
import datetime

def create_rs256_token(user_id: str, private_key_pem: bytes) -> str:
    """RS256(秘密鍵)でJWTを署名する"""
    now = datetime.datetime.now(tz=datetime.timezone.utc)
    payload = {
        "iss": "myapp",
        "sub": user_id,
        "iat": now,
        "exp": now + datetime.timedelta(minutes=30),
    }
    # 秘密鍵で署名
    token = jwt.encode(payload, private_key_pem, algorithm="RS256")
    return token

def verify_rs256_token(token: str, public_key_pem: bytes) -> dict:
    """RS256(公開鍵)でJWTを検証する"""
    try:
        # ✅ 公開鍵のみで検証できる(秘密鍵を渡す必要がない)
        decoded = jwt.decode(
            token,
            public_key_pem,
            algorithms=["RS256"],
        )
        return decoded
    except jwt.ExpiredSignatureError:
        raise ValueError("トークンの有効期限切れ")
    except jwt.InvalidTokenError as e:
        raise ValueError(f"無効なトークン: {e}")

# 動作確認
token = create_rs256_token("user_456", private_pem)
decoded = verify_rs256_token(token, public_pem)
print(f"✅ RS256検証成功 - sub: {decoded['sub']}")

HS256とRS256の使い分け

項目HS256(対称鍵)RS256(非対称鍵)
鍵の種類共有秘密鍵(1つ)秘密鍵+公開鍵(ペア)
検証側への鍵配布秘密鍵を渡す必要あり(リスク)公開鍵のみ(安全)
処理速度高速やや低速
適したケース単一サービス内の認証OAuth/OIDC・マイクロサービス
JWKSエンドポイント不要一般的に公開する

改ざん検知を実装で証明する

JWTの強みは署名によって改ざんを即座に検知できることです。ペイロードを1文字でも書き換えれば署名検証は必ず失敗します。この挙動を実際のコードで証明してみましょう。

トークンを手動で書き換えて改ざんを試みる

攻撃者がペイロード部分を書き換えてAdmin権限を奪おうとするシナリオです。

import jwt
import base64
import json

SECRET_KEY = "secure-secret-key-example"

# 正規トークンを生成(role: user)
original_payload = {"sub": "user_123", "role": "user"}
original_token = jwt.encode(original_payload, SECRET_KEY, algorithm="HS256")
print(f"正規トークン: {original_token}")

def tamper_with_payload(token: str, new_payload: dict) -> str:
    """ペイロード部分を書き換えた改ざんトークンを作る(攻撃シミュレーション)"""
    header, _, signature = token.split(".")

    # 新しいペイロードをBase64URLエンコード
    new_payload_json = json.dumps(new_payload, separators=(",", ":")).encode()
    new_payload_b64 = base64.urlsafe_b64encode(new_payload_json).rstrip(b"=").decode()

    # 元の署名はそのまま(署名を再計算できないため)
    return f"{header}.{new_payload_b64}.{signature}"

# role: admin に書き換えた改ざんトークンを作成
tampered_token = tamper_with_payload(
    original_token,
    {"sub": "user_123", "role": "admin"}  # ← 不正に権限昇格を試みる
)
print(f"\n改ざんトークン: {tampered_token}")

# 改ざんトークンを検証する
print("\n--- 検証結果 ---")
for label, token in [("正規", original_token), ("改ざん", tampered_token)]:
    try:
        decoded = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
        print(f"✅ {label}トークン: 検証成功 role={decoded.get('role')}")
    except jwt.InvalidSignatureError:
        print(f"❌ {label}トークン: 署名が無効!改ざん検知成功")
    except jwt.InvalidTokenError as e:
        print(f"❌ {label}トークン: 無効 ({e})")

例外ハンドリングの整理

PyJWT 2.x系で発生する主要な例外を整理しておきます。本番コードでは必ずこれらをキャッチしてください。

import jwt

def safe_decode(token: str, key: str, algorithms: list[str]) -> dict | None:
    """本番環境で使える安全なデコード関数"""
    try:
        return jwt.decode(token, key, algorithms=algorithms)
    except jwt.ExpiredSignatureError:
        # exp クレームが現在時刻より過去
        print("エラー: トークンの有効期限が切れています")
    except jwt.InvalidSignatureError:
        # 署名が一致しない(改ざん検知)
        print("エラー: トークンの署名が無効です(改ざんの可能性)")
    except jwt.DecodeError:
        # Base64のフォーマットが不正など
        print("エラー: トークンのデコードに失敗しました")
    except jwt.InvalidAlgorithmError:
        # 許可していないアルゴリズムが使われた
        print("エラー: 許可されていないアルゴリズムです")
    except jwt.InvalidTokenError as e:
        # 上記以外のすべてのJWT関連エラー
        print(f"エラー: 無効なトークンです ({e})")
    return None

alg:none攻撃とアルゴリズム混同攻撃を実演・防御する

JWT関連の脆弱性の中でも、alg:none攻撃は古くから知られながら現在も繰り返し発見されています。2026年3月に公開された CVE-2026-28802 は、PythonのOAuth/OIDCライブラリ「Authlib」において、alg:noneと空の署名を持つJWTが検証をバイパスできた事例です [web:4]。影響を受けたバージョンはAuthlib 1.6.5〜1.6.7未満で、攻撃者は任意のペイロードを持つJWTを偽造して認証を完全にバイパスできました [web:4]。

alg:none攻撃の実演(危険な実装)

まず、algorithmsを省略したり options で署名検証をオフにした場合に何が起きるかを確認します。

import jwt
import base64
import json

SECRET_KEY = "real-application-secret"

# 正規トークンを生成
real_token = jwt.encode(
    {"sub": "user_001", "role": "user"},
    SECRET_KEY,
    algorithm="HS256"
)

def forge_alg_none_token(payload: dict) -> str:
    """alg:noneトークンを偽造する(攻撃シミュレーション)"""
    # headerのalgをnoneに変更
    header = {"alg": "none", "typ": "JWT"}
    header_b64 = base64.urlsafe_b64encode(
        json.dumps(header, separators=(",", ":")).encode()
    ).rstrip(b"=").decode()

    payload_b64 = base64.urlsafe_b64encode(
        json.dumps(payload, separators=(",", ":")).encode()
    ).rstrip(b"=").decode()

    # 署名部分を空にする
    return f"{header_b64}.{payload_b64}."

# role: admin で偽造トークンを作成
forged_token = forge_alg_none_token({"sub": "attacker", "role": "admin"})
print(f"偽造トークン: {forged_token}")

# ❌ 危険:verify_signatureをFalseにすると偽造トークンが通る
try:
    decoded = jwt.decode(
        forged_token,
        options={"verify_signature": False},  # ← 絶対に本番で使ってはいけない
        algorithms=["HS256", "none"],
    )
    print(f"⚠️  署名なしデコード(本番では禁止): {decoded}")
except Exception as e:
    print(f"エラー: {e}")

安全なjwt.decode()の書き方

正しい実装では、必ず algorithms に許可するアルゴリズムのリストを明示し、none を含めないことが鉄則です。

import jwt

SECRET_KEY = "real-application-secret"

def secure_decode(token: str) -> dict:
    """
    ✅ alg:none攻撃・アルゴリズム混同攻撃を防ぐ安全なデコード実装
    CVE-2026-28802の教訓:許可アルゴリズムは必ず明示的に列挙する
    """
    try:
        decoded = jwt.decode(
            token,
            SECRET_KEY,
            algorithms=["HS256"],  # ✅ 許可アルゴリズムを明示(noneは絶対含めない)
            options={
                "require": ["exp", "iat", "sub"],  # 必須クレームを強制
                "verify_exp": True,                 # 有効期限を検証(デフォルトTrue)
                "verify_signature": True,           # 署名検証を有効(デフォルトTrue)
            },
        )
        return decoded
    except jwt.InvalidAlgorithmError:
        raise ValueError("許可されていないアルゴリズムが使用されました(alg:none攻撃の可能性)")
    except jwt.MissingRequiredClaimError as e:
        raise ValueError(f"必須クレームが不足しています: {e}")
    except jwt.InvalidTokenError as e:
        raise ValueError(f"トークンが無効です: {e}")

# alg:noneトークンが拒否されることを確認
forged = forge_alg_none_token({"sub": "attacker", "role": "admin"})  # 上のセルで定義済み
try:
    result = secure_decode(forged)
    print("⚠️  偽造トークンが通過(実装に問題あり)")
except ValueError as e:
    print(f"✅ 偽造トークンを正しく拒否: {e}")

RS256/HS256混同攻撃の防御

RS256で署名されたトークンをHS256として検証しようとすると、公開鍵を秘密鍵として使ってしまうリスクがあります。algorithms リストには使用するアルゴリズム1種のみを指定するのが最善策です [web:6]。複数のアルゴリズムをサポートする場合は、issクレームや鍵IDに基づいてアルゴリズムと鍵を対応付けるロジックを必ず実装してください。

有効期限・リフレッシュトークンの実装パターン

実際のアプリケーションでは、短命のアクセストークン(AT)と長命のリフレッシュトークン(RT)を組み合わせるパターンが標準的です。ATが漏洩しても被害を最小化し、RTで再発行できる設計です。

expクレームで自動失効するトークン生成

import jwt
import datetime

SECRET_KEY = "production-secret-change-me"
REFRESH_SECRET = "refresh-token-secret-different-from-access"

def create_access_token(user_id: str) -> str:
    """短命アクセストークン(15分)を生成する"""
    now = datetime.datetime.now(tz=datetime.timezone.utc)
    payload = {
        "sub": user_id,
        "type": "access",
        "iat": now,
        "exp": now + datetime.timedelta(minutes=15),  # 短命設定
    }
    return jwt.encode(payload, SECRET_KEY, algorithm="HS256")

def create_refresh_token(user_id: str) -> str:
    """長命リフレッシュトークン(7日)を生成する"""
    now = datetime.datetime.now(tz=datetime.timezone.utc)
    payload = {
        "sub": user_id,
        "type": "refresh",
        "iat": now,
        "exp": now + datetime.timedelta(days=7),  # 長命設定
    }
    # ✅ リフレッシュトークンはアクセストークンとは別の秘密鍵を使う
    return jwt.encode(payload, REFRESH_SECRET, algorithm="HS256")

# 両方のトークンを生成
access_token = create_access_token("user_789")
refresh_token = create_refresh_token("user_789")
print(f"AT: {access_token[:40]}...")
print(f"RT: {refresh_token[:40]}...")

ExpiredSignatureErrorのハンドリングとリフレッシュパターン

import jwt
import datetime

def refresh_access_token(refresh_token: str) -> str | None:
    """
    リフレッシュトークンを検証して新しいアクセストークンを発行する
    RTが有効な場合のみ新しいATを返す
    """
    try:
        # ✅ リフレッシュトークンは別の鍵・別のアルゴリズムリストで検証
        payload = jwt.decode(
            refresh_token,
            REFRESH_SECRET,
            algorithms=["HS256"],
        )
        # typeクレームでリフレッシュトークンであることを確認
        if payload.get("type") != "refresh":
            raise ValueError("リフレッシュトークンではありません")

        user_id = payload["sub"]
        # 新しいアクセストークンを発行
        new_access_token = create_access_token(user_id)
        print(f"✅ 新しいATを発行しました (sub={user_id})")
        return new_access_token

    except jwt.ExpiredSignatureError:
        print("❌ リフレッシュトークンの有効期限切れ - 再ログインが必要")
        return None
    except jwt.InvalidTokenError as e:
        print(f"❌ 無効なリフレッシュトークン: {e}")
        return None

def verify_access_token_with_refresh_fallback(
    access_token: str,
    refresh_token: str
) -> dict | None:
    """
    ATの検証を試み、期限切れなら自動的にRTでリフレッシュする
    FastAPIのミドルウェアやFlaskのbefore_requestで使うパターン
    """
    try:
        # まずATを検証
        return jwt.decode(access_token, SECRET_KEY, algorithms=["HS256"])
    except jwt.ExpiredSignatureError:
        print("ATが期限切れ → リフレッシュを試みます...")
        new_at = refresh_access_token(refresh_token)
        if new_at:
            # 新しいATでデコード
            return jwt.decode(new_at, SECRET_KEY, algorithms=["HS256"])
        return None
    except jwt.InvalidTokenError as e:
        print(f"ATが無効: {e}")
        return None

# 動作確認
result = verify_access_token_with_refresh_fallback(access_token, refresh_token)
if result:
    print(f"認証成功: {result['sub']}")

通信ログにJWTが平文で流れていないか確認するには、WebSocketやHTTP通信のキャプチャが必要です。Pythonを使ったネットワーク通信の傍受・監査については「PythonでWebSocket通信をリアルタイム傍受・ログ解析する実装ガイド」を参照してください。

まとめ・FAQ・次の記事への導線

この記事では、python jwt 署名 検証 実装の基礎から実践的なセキュリティ対策まで、一通りのコードを手を動かして確認しました。

まとめ

  • JWTはBase64URLエンコードなのでペイロードは誰でも読める。機密情報は入れてはいけない
  • jwt.decode() の algorithms パラメータは必ず明示的に指定し、none を含めないことが大原則(CVE-2026-28802の教訓)
  • HS256は単一サービス向け・RS256は公開鍵配布が必要な複数サービス間の認証に使い分ける
  • 本番認証には短命AT(15分)+長命RT(7日)の組み合わせが標準パターン
  • PyJWT 2.x系は1.x系と encode() の戻り値型・algorithms の必須化が異なるため、バージョン確認を忘れずに

次に読むべき記事

  • PythonでWebSocket通信をリアルタイム傍受・ログ解析する実装ガイド
  • Pythonで自分のWebアプリにSQLインジェクション診断を自動化する
  • FastAPI + PyJWT で作るOAuth2.0認証サーバー完全実装ガイド

よくある質問(FAQ)

Q. JWEとJWSの違いは何ですか?
A. JWS(JSON Web Signature)はペイロードに署名するだけで内容は読めます(この記事で扱ったJWT)。JWE(JSON Web Encryption)はペイロード自体を暗号化するため、中身を第三者が読めません。ユーザーIDや権限などの情報を第三者に見せたくない場合はJWEを使います。PyJWTはJWSのみサポートで、JWEには別途 python-jose や jwcrypto ライブラリが必要です。

Q. JWTをCookieに入れる場合、SameSite設定はどうすればいいですか?
A. CSRF攻撃対策として SameSite=Strict または SameSite=Lax を設定します。さらに HttpOnly=True(JavaScriptからアクセス不可)と Secure=True(HTTPS通信のみ)を必ず組み合わせてください。LocalStorageに保存するとXSSで盗まれるリスクがあるため、Cookie保存が推奨です。FlaskなOnce response.set_cookie('token', value=jwt_token, httponly=True, secure=True, samesite='Lax') のように設定します。

Q. PyJWT 1.x系から2.x系に移行する際の主な変更点は?
A. 主に3点あります。①jwt.encode() の戻り値が bytes → str に変更(1.x系の .decode('utf-8') が不要に)。② jwt.decode() の algorithms パラメータが必須になった。③ jwt.decode() の options 辞書の構造が変わった(verify_exp などが options 内のキーに)。既存コードは PyJWT==1.7.x と PyJWT==2.x で動作が変わるため、pip show PyJWT でバージョンを確認してから移行してください。