2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【セキュリティ】API時代のJWT一気に理解する

2
Last updated at Posted at 2025-11-13

はじめに

現代のソフトウェア開発では、API(Application Programming Interface)があらゆるサービスの中心的役割を担うようになりました。
この急速な普及の背景には、「単一の API で複数のインターフェイスを支えられる」という構造的メリットがあります。


APIの台頭

1つのAPIで複数プラットフォームをサポート

従来は、Web、スマホアプリ、デスクトップアプリなど、クライアントごとに個別のロジックを書く必要がありました。
しかし API 方式では、

  • Web アプリ
  • モバイルアプリ
  • IoT デバイス

といった異なるクライアントが、同じ API にアクセスして同じサーバーロジックを共有できます。

その結果として、

  • 重複開発の削減
  • 保守性の向上
  • アプリ間の仕様ズレ防止

といったメリットが生まれ、開発効率は飛躍的に高まりました。

セキュリティ上の利点

API を中心に据えた設計では、認証・認可といった重要なセキュリティ処理をサーバ側に一元管理できます。
これにより、クライアントごとにバラバラな実装になることを避けられます。

つまり、

どのクライアントから来ても、API が同じセキュリティルールで守ってくれる。

という状態を作れるわけです。


歴史と成り立ち

出来事
2010〜2012年頃 OAuth 2.0 や OpenID Connect の構想期。セッション管理の課題を解決するため、「自己完結型トークン」への関心が高まる。
2014年 JWT の草案(draft)が出され、API 認証界隈で実装が増える。
2015年5月 RFC 7519 “JSON Web Token (JWT)” が正式発行。
2017年以降 OpenID Connect Core 1.0 で採用され、OAuth 2.0 の Access Token / ID Token 形式としてデファクト化。
現在 API、SSO、モバイルバックエンド、マイクロサービス間通信などで標準的な手段に。

JWTの構造(3つのパーツで構成)

JWT は次の 3 つのパートを Base64URL エンコードし、「.」で連結したものです。

header.payload.signature

1. Header(ヘッダー)

主なフィールド:

  • typ: "JWT"
  • 使用する署名アルゴリズム(例:HS256, RS256

例:

{
  "typ": "JWT",
  "alg": "HS256"
}

2. Payload(ペイロード)

JWT の本体。ユーザー情報などの クレーム(Claim) が含まれます。

主な種類:

  • Registered Claims(登録済みクレーム)
    iss, sub, aud, exp など標準で定義されたもの
  • Public Claims / Private Claims
    開発者が自由に定義するカスタム情報(例:role, "admin": 1 など)

※この記事では、セキュリティ上重要なポイントに焦点を当てるため、細かい分類の深掘りは省略します。

例:

{
  "username": "user",
  "admin": 0
}

3. Signature(署名)

署名は JWT の信頼性を保証する最重要部分 です。

signature = Sign(
  base64url(header) + "." + base64url(payload),
  secret_or_private_key
)

これにより、攻撃者がペイロードを勝手に書き換えても、
署名検証に失敗してトークンが無効化されます。


署名アルゴリズムの種類

JWT で使われる署名アルゴリズムはいくつかありますが、実務で特に重要なのは次の 3 つです。

1. None

  • 「署名なし」の意味
  • 実質 偽造し放題 の危険な状態
  • 本来は特殊環境向けだが、実装を誤ると重大な脆弱性になる

2. Symmetric(対称鍵署名:HS256 など)

  • 共有秘密鍵(single secret)で署名
  • 検証も同じ秘密鍵で行う

弱い秘密鍵を使うと、オフライン総当たり攻撃で突破される危険があります。

3. Asymmetric(公開鍵暗号:RS256 など)

  • 署名:秘密鍵(private key)
  • 検証:公開鍵(public key)

アプリ側が公開鍵を持っていれば、
認証サーバが署名した JWT を安全に検証できます。


よくあるミス

  • 機密情報の開示(ペイロードにパスワード/ハッシュなどを入れる)
  • 署名を検証しない(単に base64 デコードして中身だけ信じる)
  • alg: "none" へのダウングレードを許容
  • HS256 で 弱い秘密鍵 を使い、hashcat -m 16500 などでオフライン総当たり可能
  • RS256 ↔ HS256 アルゴリズム混同(公開鍵を HS の「秘密鍵」として扱う)
  • exp 未設定・過度に長寿命なトークン
  • aud 未検証のクロスサービス中継(Cross-Service Relay)
  • admin などの権限クレームを、DB 再チェックなしでそのまま信じる

テスト観点チェックリスト(Red / Blue 両利き)

攻撃者視点(レッドチーム)

  • 署名未検証の疑い:署名部分を壊す/削除しても通るか
  • alg: "none":Header を改ざんしても API が受け付けるか
  • HS256 弱鍵hashcat -m 16500 などで辞書攻撃を試せるか
  • RS→HS 混同alg を HS に変更し、「公開鍵」を HS 用の秘密鍵として指定しても通るか
  • 期限まわりexp なし/過去・未来の iat / nbf でバイパスできないか
  • Audienceaud実サービスで検証されているか(他サービスからの越境利用ができないか)
  • 保存場所localStorage に保存していて、XSS で窃取できそうか
  • High-risk クレームadmin などのフラグが真偽の単一ソースになっていないか(DB 側で再確認しているか)

実装者視点(ブルーチーム)

  • アルゴリズム固定(受け入れる alg をホワイトリストで固定)
  • 鍵種別の分離(RS は公開鍵・秘密鍵、HS は共有秘密…を混同しない)
  • 短寿命 Access Token + Refresh Token ローテーション
    + 失効リスト(jti)によるトークン無効化
  • iss / aud / sub / nbf / iat / exp必須クレームとして検証
  • 権限はサーバ側で再確認
    (トークン内の admin だけを真実として扱わない)
  • Cookie (HttpOnly, Secure, SameSite) での保存
    または メモリ上の保持+CSRF対策 を組み合わせる
  • キー管理:KMS / Vault などによる保護、ローテーション、キーバージョンの管理、短期失効
  • 観測・監査:署名失敗回数、kid 不正利用、異常な aud などのメトリクスを監視

開発者向け安全雛形(Python / Flask + PyJWT)

from flask import Flask, request, jsonify
import jwt, os, datetime

SECRET = os.environ["JWT_HS_SECRET"]
ISS = "https://auth.example.com"
AUD = "appA"

app = Flask(__name__)

def issue_access(sub: str) -> str:
    now = datetime.datetime.now(datetime.timezone.utc)
    payload = {
        "iss": ISS,
        "sub": sub,
        "aud": AUD,
        "iat": now,
        "nbf": now,
        "exp": now + datetime.timedelta(minutes=10),
    }
    return jwt.encode(payload, SECRET, algorithm="HS256")

def verify_access(token: str) -> dict:
    return jwt.decode(
        token,
        SECRET,
        algorithms=["HS256"],
        audience=[AUD],
        issuer=ISS,
        options={"require": ["iss", "sub", "aud", "iat", "nbf", "exp"]},
    )

@app.get("/me")
def me():
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        return jsonify({"error": "No token"}), 401

    token = auth.split(" ", 1)[1]
    try:
        claims = verify_access(token)
        return jsonify({"sub": claims["sub"], "aud": claims["aud"]})
    except Exception as e:
        return jsonify({"error": str(e)}), 401

まとめ

  • アルゴリズム固定と鍵種別の厳格運用(RS と HS を混ぜない)
  • 短寿命 Access Token + Refresh Token ローテーション
    exp / iat / nbf / iss / aud / sub の必須化・検証
  • aud実サービス側で検証し、クロスサービス中継を遮断
  • 権限はサーバ側で再確認し、トークンの admin を盲信しない
  • キー管理と監査(KMS・ローテーション・署名失敗検知・kid 監視)

Tools & Documents

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?