JWTのヘッダ・ペイロード・署名の3層構造を実例で読み解く

約7分

JWT(JSON Web Token)を扱う場面では、eyJhbGciOiJIUzI1NiIs... のような長い文字列が登場します。Decoder にかければ中身は見えますが、なぜこの形式なのかを理解しておかないと、トークン検証を実装するときに細部で詰まりがちです。本記事では JWT の構造を実装の都合に沿って分解します。

全体構造:3つのBase64URL文字列をピリオドで繋いだだけ

JWT の正体は驚くほど単純で、以下のフォーマットに従います。

<base64url(header)>.<base64url(payload)>.<base64url(signature)>

実際のトークンを . で分割すると、必ず3つの部分に分かれます。split('.').length === 3 を最初の妥当性チェックに使えるくらい、この構造は厳格です。

仕様は RFC 7519 で定義されていて、署名アルゴリズムの仕様(JWS)は RFC 7515、暗号化(JWE)は RFC 7516 に分かれています。世間で「JWT」と呼ばれているものは、ほぼ JWS(署名済み JWT)を指しています。

ヘッダ:署名アルゴリズムを宣言する短い JSON

最初のセクションはヘッダで、典型的にはこんな JSON を Base64URL でエンコードしたものです。

{
	"alg": "HS256",
	"typ": "JWT"
}
  • alg は署名アルゴリズム。HMAC系(HS256 / HS384 / HS512)と RSA系・ECDSA系(RS256, ES256 など)がよく使われます
  • typ は形式の宣言で、JWT であれば "JWT"

alg: "none" の罠

JWT 仕様には alg: "none" という「署名なし」の指定が存在します。歴史的に多くのライブラリがこれを誤って受け入れる脆弱性があり、攻撃者がヘッダを none に書き換えることで署名検証をスキップさせるケースがありました。自前で検証コードを書く場合、alg のホワイトリストを明示的に持って、想定外のアルゴリズムは弾く実装にすべきです。

ペイロード:クレームと呼ばれる任意のJSON

2番目のセクションがペイロードで、ここに認証情報や任意のデータが入ります。

{
	"sub": "user-1234",
	"name": "ibukish",
	"iat": 1700000000,
	"exp": 1700003600
}

ペイロードに含めるキーは「クレーム」と呼ばれ、3種類に分類されます。

  • 登録済みクレーム:仕様で予約されている短い名前。iss(発行者)、sub(subject)、aud(audience)、exp(有効期限)、iat(発行時刻)、nbf(有効開始時刻)、jti(一意ID)など
  • 公開クレーム:他者と衝突しないよう、URI 形式や IANA レジストリで管理される名前
  • プライベートクレーム:自社サービス内だけで使う独自フィールド

実装で特にハマりやすいのが時刻系クレームの単位です。expiatUnix秒(秒単位) であって、JavaScript の Date.now()(ミリ秒)ではありません。1000で割り忘れると、有効期限が38年後(2038年問題のさらに先)になってしまい、検証側が「未来すぎる」と弾いて初めて気付く、という事故が起こりがちなフィールドです。

署名:トークンの改ざんを検出する仕組み

3番目のセクションが署名で、これが JWT を「ただの Base64 化された JSON」から「信頼できるトークン」に変える要です。

署名対象は ヘッダとペイロードを Base64URL エンコードしてピリオドで繋いだ文字列 です。署名そのものではなく、署名対象を理解しておくことが重要:

signing_input = base64url(header) + "." + base64url(payload)
signature = HMAC_SHA256(signing_input, secret)   // alg = HS256 の場合

検証側は受け取ったトークンを . で3分割し、最初の2つをそのまま結合して同じアルゴリズムで署名を再計算、トークンの3番目のセクションと一致するかを確認します。

Base64 ではなく Base64URL を使う理由

JWT は URL の一部やヘッダに載ることが多いため、+/ などの URL で問題になる文字を避けた Base64URL を使います。具体的には:

  • +-
  • /_
  • 末尾の = パディングを削除

デコーダを実装する場合、-+ に、_/ に戻し、長さを4の倍数になるよう = でパディングしてから標準の Base64 デコードに渡す、という前処理が必要です。

「Base64で見えるならセキュリティ無いのでは?」という誤解

JWT に対して「ペイロードが Base64 で簡単に読めるなら何の意味もないのでは?」という疑問はよく挙がりますが、これは誤解です。

JWT の保証は機密性ではなく完全性(integrity)です。中身は誰でも読める前提で設計されていて、署名によって「サーバーが署名したそのままの内容で届いている」ことを検証可能にしています。秘密にしたい情報を JWT のペイロードに入れるのは間違いで、機密性が必要なら JWE(暗号化された JWT)を使うか、そもそもサーバー側のセッションストアで管理すべきです。

実装で押さえておきたいポイント

JWT 周りの実装で、特に気をつけるべき点をまとめます。

  • alg のホワイトリスト:受け入れるアルゴリズムを固定し、none や想定外のアルゴリズムを必ず拒否する
  • 時刻クレームは秒単位Date.now() / 1000 を忘れない
  • HMAC の secret は十分に長く:HS256 なら最低でも 256 bit(32バイト)以上のランダム値
  • 公開鍵検証のキャッシュ:JWKS から公開鍵を取得する場合、毎リクエストで取りに行かずキャッシュを設ける
  • clock skew を考慮exp 検証時に数秒〜数十秒の余裕を持たせる

中身を確認したいだけなら、本サイトのデコーダに貼り付けるのが手早いです。署名検証は省略され、ヘッダとペイロードだけブラウザ内でデコードされるので、トークンの中身が外部に送られる心配もありません。