2-1. JWT - 構造・署名・脆弱性・運用

所要時間: 40-60分(がっつりなら2セッション分) ゴール: JWT の構造を完全に理解し、署名アルゴリズムを選び、よくある脆弱性を避けて Go で実装できる コミット内容: ~/learn/security/day-jwt/ に署名・検証コードと alg=none 検証用テストを残す


前章とのつながり

このレッスンの位置づけ

Level 1 で「認証 / 認可 / 通信路 / 入力対策」を概観した。Level 2 は 「実装の深掘り」。最初の本章 JWT は、認証結果を どう運ぶか の現代的な代表解。

  • 1-1 認証と認可 で予告した「トークン認証」の具体形
  • 次章 2-2 セッション管理: JWT と対比される 伝統的セッション を学ぶことで、両者の使い分けが鮮明になる
  • 1-6 XSS で出た「localStorage に JWT 保存は危険」の理由が本章で深まる

実害シーン: JWT の実害

  • OSS JWT ライブラリの alg=none バイパス (2015-): 数十のライブラリで「署名なし」を許す実装 → 任意の payload を偽造して 管理者なりすまし が量産
  • CVE-2022-21449 (Java ECDSA): 「全部ゼロ署名」が通る → 攻撃者が 任意の JWT を作り放題
  • SPA + localStorage + XSS: 一度 XSS を踏むと全 JWT が盗まれ、サーバー側で失効不能 のためログアウトしても乗っ取り継続

JWT は 間違って実装すると一発で全アカウント乗っ取り が起きる。「ライブラリ任せ」では足りず、自分が何をしているか を理解しないと事故る。


大前提: なぜ JWT を学ぶか

JWT(JSON Web Token, RFC 7519)は、現代のWebサービスで認証情報をやり取りする最も普及した形式です。Auth0、Cognito、Firebase Auth、Okta、Google ID Token、Kubernetes ServiceAccount Token ── すべて JWT を返す。

「ログインしたユーザーを識別する」「マイクロサービス間でユーザー情報を伝える」「OAuth/OIDC のID Token」など、用途は広い。

しかし、JWT は「正しく使えば便利、間違うと一発で全アカウント乗っ取り」 という性格を持つ。実際に過去の事故事例:

  • 2015年に多数の OSS JWT ライブラリで横行した alg=none バイパス(CVE-2015-9235 他): 「署名なし」を許す実装で、攻撃者が任意のペイロードを偽造可能だった
  • CVE-2018-1000531 (jsonwebtoken-node): 公開鍵を HMAC 秘密鍵として扱う混同バグ
  • CVE-2022-21449(Java の ECDSA バグ): 「全部ゼロ」の署名が通ってしまう実装ミス、Oracle JDK 系で発覚
  • GitHub・Slack 等での「localStorage に JWT 保存 → XSSで奪取」事例多数

このレッスンでは、構造・署名・運用・脆弱性のすべてを「攻撃者と防御者の両視点」で扱う。


セッション①: JWT の構造と署名(30分)

1. JWT の3ピース構造

<header>.<payload>.<signature>

例(改行は説明のため):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0IiwibmFtZSI6IlRha2F0byIsImV4cCI6MTczMDAwMDAwMH0
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

それぞれ Base64URL でデコードすると:

// header
{"alg":"HS256","typ":"JWT"}
 
// payload (claims)
{"sub":"1234","name":"Takato","exp":1730000000}
 
// signature の中身
HMAC-SHA256( base64url(header) + "." + base64url(payload), secret )
の生バイトを Base64URL エンコードしたもの

JWT の本質

「サーバーが署名した JSON」。それだけ。

  • 改ざんできない: 署名があるから、ペイロードを書き換えると検証失敗する
  • ステートレス: サーバー側で「ログイン中ユーザー一覧」を持たなくていい。トークンが自己完結
  • 検証だけなら鍵さえあれば誰でもできる: マイクロサービス間で「認証サーバーを叩かずに」検証可能

暗号化ではない。Base64URL は誰でもデコードできる。payload は「公開された情報」と思って設計する

Base64URL は Base64 と違う

通常の Base64 は + / = を使うが、これらは URL で問題を起こす(+ が空白に化ける等)。

Base64URL は:

  • +-
  • /_
  • 末尾の = パディングは省略

JWT は HTTP Header(Authorization: Bearer ...)や URL に乗せるため、Base64URL を採用している。

2. 署名アルゴリズム: HS256 / RS256 / ES256

アルゴ種類使い所
HS256対称鍵 (HMAC-SHA256)同じ秘密鍵で署名・検証同一サービス内で完結する場合
RS256非対称鍵 (RSA-SHA256)秘密鍵で署名、公開鍵で検証複数サービス・サードパーティに検証させる場合
ES256非対称鍵 (ECDSA P-256)秘密鍵で署名、公開鍵で検証RS256 と同じ用途、署名が短く高速

HS256 vs RS256/ES256 の選択基準

HS256 が向くケース

  • 1つのモノリスサービス内だけで JWT を発行・検証
  • シンプル、鍵管理が1つで済む
  • 計算が速い(HMAC は超高速)

RS256 / ES256 が向くケース

  • 複数のマイクロサービスが検証する(認証サーバーだけ秘密鍵、他サービスは公開鍵)
  • サードパーティに「このトークンが本物か」検証させたい(OIDC の ID Token)
  • JWKS エンドポイント (/.well-known/jwks.json) を公開して鍵ローテーション対応する

ES256 を選ぶ動機: RS256 と同等の安全性で署名サイズが約 1/4(64 vs 256 バイト)。トークンサイズが小さいと Cookie / Header に乗せやすい。

HS256 と RS256 を混ぜると死ぬ歴史的バグ

古い JWT ライブラリには、RS256 で発行されたトークンを HS256 として検証してしまう脆弱性があった(CVE-2015-9235 系)。攻撃者がヘッダだけ alg: HS256 に書き換え、公開鍵を「HMAC の秘密鍵」として渡されたサーバが検証成功してしまう

公開鍵は公開されているから、攻撃者は秘密鍵を知らなくても署名を偽造できた。

対策: 検証側は許可するアルゴリズムを明示(後述のGo実装で必須化)。

3. alg=none 脆弱性(教科書事例)

header:  {"alg":"none","typ":"JWT"}
payload: {"sub":"admin","exp":9999999999}
signature: (空)

これを header.payload. (末尾ドット)として送ると、「署名なしを許可する」実装は通してしまう

alg=none は JWT 仕様の最大の罠

RFC 7519 / 7515 では alg: none が「アンセキュアな JWT」として定義されている(鍵交換前のデバッグ用など)。

これを「ユーザーから受け取った JWT」で許してしまうと完全死亡

過去事例:

  • 2015年: Auth0 をはじめ多数の OSS ライブラリで alg=none バイパスが発見され、CVE-2015-9235 などで報告
  • 攻撃者は payloadsubadmin に書き換え、署名なしで送るだけで管理者になれた

防御:

  • 検証時に許可アルゴリズムをホワイトリスト化alg: none を絶対に通さない)
  • ライブラリのバージョンを最新化
  • 受信側で header.alg を信用しない(事前に「このトークンは RS256 のはず」と固定する)

alg=none を許してしまうコード

// NG: alg を検証していない
token, _ := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
    return []byte("secret"), nil  // alg=none でも呼ばれてしまう
})
if token.Valid {
    // 攻撃者の偽トークンが通る
}

なぜNGか: jwt.Parse のコールバック内で t.Method をチェックしていない。alg=none の場合 Method.Verify が「何もせず true 」を返す実装があり、token.Valid が真になる。

4. Go で正しく署名・検証する(golang-jwt/jwt v5)

mkdir -p ~/learn/security/day-jwt
cd ~/learn/security/day-jwt
go mod init jwt-demo
go get github.com/golang-jwt/jwt/v5
// main.go
package main
 
import (
    "errors"
    "fmt"
    "os"
    "time"
 
    "github.com/golang-jwt/jwt/v5"
)
 
// 秘密鍵は環境変数から(コードに書かない)
var hmacSecret = []byte(os.Getenv("JWT_SECRET"))
 
// 発行
func issueToken(userID string) (string, error) {
    claims := jwt.MapClaims{
        "sub": userID,
        "iat": time.Now().Unix(),
        "exp": time.Now().Add(15 * time.Minute).Unix(), // 15分で失効
        "iss": "myapp",
        "aud": "myapp-api",
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(hmacSecret)
}
 
// 検証
func verifyToken(tokenString string) (jwt.MapClaims, error) {
    token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
        // 重要: 期待するアルゴリズムを明示的にチェック
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("予期しない署名方式: %v", t.Header["alg"])
        }
        return hmacSecret, nil
    },
        jwt.WithValidMethods([]string{"HS256"}), // v5 の推奨書き方
        jwt.WithIssuer("myapp"),
        jwt.WithAudience("myapp-api"),
        jwt.WithExpirationRequired(),
    )
    if err != nil {
        return nil, err
    }
    claims, ok := token.Claims.(jwt.MapClaims)
    if !ok || !token.Valid {
        return nil, errors.New("invalid token")
    }
    return claims, nil
}
 
func main() {
    t, _ := issueToken("user-1234")
    fmt.Println("token:", t)
    c, err := verifyToken(t)
    if err != nil {
        fmt.Println("verify error:", err)
        return
    }
    fmt.Println("sub:", c["sub"])
}

正しい検証コードのポイント

  • jwt.WithValidMethods([]string{"HS256"})alg=none / RS256 偽装を拒否
  • jwt.WithIssuer / jwt.WithAudience発行者・対象を固定(他サービスの JWT を流用されない)
  • jwt.WithExpirationRequired()exp 必須化(無期限トークンを禁止)
  • シークレットは環境変数や Secrets Manager から取る(シークレット管理 参照)

alg=none を試して拒否されることを確認

# alg=none の偽トークンを生成
python3 -c "
import base64, json
h = base64.urlsafe_b64encode(json.dumps({'alg':'none','typ':'JWT'}).encode()).rstrip(b'=').decode()
p = base64.urlsafe_b64encode(json.dumps({'sub':'admin','exp':9999999999}).encode()).rstrip(b'=').decode()
print(f'{h}.{p}.')
"

このトークンを verifyToken に渡すと WithValidMethods がはじいてくれる。検証コードを書いたら必ずこのテストをやる。


セッション②: 運用・脆弱性・設計(30分)

5. ペイロード設計(claims)

JWT には予約 claims がある:

claim意味必須度
iss (issuer)発行者推奨
sub (subject)主体(普通はユーザーID)推奨
aud (audience)対象推奨
exp (expiration)失効時刻(Unix秒)ほぼ必須
nbf (not before)有効開始時刻任意
iat (issued at)発行時刻推奨
jti (JWT ID)一意ID(失効管理用)任意

加えてカスタム claims を入れられる(role, tenant_id など)。

payload に入れてはいけないもの

JWT は Base64URL で誰でもデコードできる。暗号化されない。

NG例:

  • パスワード(ハッシュも含む)
  • クレジットカード番号
  • 個人情報(住所、電話、本名フル)
  • 内部API キー、DB接続文字列
  • セッション固有の秘密値

OK例:

  • ユーザーID(推測可能でも、署名で改ざんを防ぐ)
  • ロール、テナントID
  • 表示名(公開前提のもの)

アンチパターン: payload に機密を入れる

// NG: 暗号化されないのにメールアドレスをそのまま、パスワードも
claims := jwt.MapClaims{
    "sub":      userID,
    "email":    "user@example.com",      // ログに残る、URL に乗る可能性
    "password": "hashedPassword!@#",     // 論外
    "api_key":  "sk_live_abc123",        // 論外
}

なぜNGか: JWT は HTTP Header に乗る → アクセスログ・プロキシログ・APM に残る。GitHub の Issue にペーストされる事故も多発している。

6. アクセストークン vs リフレッシュトークン

ログイン
  ↓
[アクセストークン: 15分] + [リフレッシュトークン: 30日]
  ↓
API リクエスト時は Access を使う(短命)
  ↓
Access が切れたら Refresh で新しい Access を取得
  ↓
Refresh が切れたら再ログイン

なぜ2種類に分けるのか

JWT は失効が困難だから。署名さえ通れば exp まで有効。「ログアウト」「権限剥奪」を即時反映できない。

そこで:

  • アクセストークンは短命(5-30分)にして、影響範囲を小さく
  • リフレッシュトークンは長命だがサーバー側で管理(DB に格納、無効化可能)

アクセストークンが漏れても、せいぜい15分の被害で済む。リフレッシュトークンは盗まれた時にサーバー側で即無効化できる。

回転(rotation): リフレッシュを使うたびに新しいリフレッシュを発行し、古いやつは無効化する設計が推奨(盗まれたリフレッシュの使い回しを検知できる)。

7. トークン失効問題

JWT を「ログアウト即無効」にする方法は本質的に難しい。選択肢:

  1. 短命設計(5-15分): 失効を諦めて、影響を時間で限定する(最も多い)
  2. ブラックリスト方式: 失効した jti を Redis に入れて、検証時に毎回見る
  3. ホワイトリスト方式: 有効な jti を DB に持つ(実質セッション、JWT である意味が薄れる)
  4. バージョン番号方式: ユーザーごとに token_version を持ち、payload に入れる。ログアウト時にDBの番号をインクリメント、検証時に DB と比較

実務での選択

  • ログアウト即時反映が必須でない(B2C アプリの一般用途): 短命 + リフレッシュ回転
  • 管理者の権限剥奪を即時反映したい(B2B SaaS、金融): バージョン番号方式 or セッション併用
  • 規模が大きい: ブラックリスト方式(Redis に exp までだけ保持、TTLで自動削除)

JWT を選ぶ前に「失効をどう扱うか」を必ず決める。これを後回しにすると痛い目に遭う。

8. 秘密鍵管理と鍵ローテーション

HS256 の場合:

  • シークレットは 最低 256bit(32バイト)のランダム値
  • 環境変数 or Secrets Manager から読み込む(コードに書かない)
  • 定期的にローテーション(年1回 or インシデント時)

RS256 / ES256 の場合:

  • 秘密鍵は HSM / KMS に格納が理想(最低でも Secrets Manager)
  • 公開鍵を JWKS エンドポイントで配布: https://example.com/.well-known/jwks.json
  • JWKS は kid (key ID) で複数の鍵を持てる → 新旧の鍵を並走させて段階移行

JWKS の中身(簡略)

{
  "keys": [
    {"kid": "2026-05", "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "alg": "ES256"},
    {"kid": "2026-02", "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "alg": "ES256"}
  ]
}

JWT の header に kid: 2026-05 を入れて、検証側はそれに対応する公開鍵で検証。新しい鍵を追加 → 数週間並走 → 古い鍵を削除、という流れでローテーション。

9. アンチパターン総まとめ

JWT を localStorage に保存

// NG: XSS で奪取される
localStorage.setItem('token', jwt);

なぜNGか: localStorage は JavaScript から自由に読める。サイト内に XSS が1箇所でもあれば、localStorage.token を攻撃者サーバーに送信される。

対策: HttpOnly + Secure + SameSite cookie に保存(セッション管理 参照)。Cookie なら JavaScript からは読めない。

無期限トークン

// NG: exp なし
claims := jwt.MapClaims{
    "sub": userID,
}

なぜNGか: 一度発行したら永久に有効。漏洩したら永遠に攻撃可能。

検証で alg をチェックしない

// NG: コールバックで Method を確認していない
jwt.Parse(s, func(t *jwt.Token) (interface{}, error) {
    return secret, nil  // alg=none も RS256 も通る
})

シークレットがハードコード

// NG
var secret = []byte("mysecret123")

なぜNGか: ソースを GitHub に push した瞬間世界中に公開。Git 履歴に残ると後から削除しても危険。truffleHog などのスキャナが Git 履歴を漁って秒で発見する。

10. JWT を使う / 使わない判断

JWT が向くケース

  • マイクロサービス間で認証情報を伝搬したい(認証サーバーへの問い合わせを減らせる)
  • OAuth2 / OIDC を実装(標準仕様で JWT 必須に近い)
  • モバイルアプリ × API(Cookie が使えない場面)
  • サードパーティに公開鍵で検証させたい

JWT より伝統的セッションが向くケース

  • モノリスの Web アプリ(フロントもバックも同じ origin)
  • ログアウト即時反映が必須(管理画面、銀行)
  • トークン取り消しを頻繁に行う
  • payload に頻繁に変わる情報を入れたい(権限の動的変更)

Cookie + サーバーサイドセッションは枯れていて事故が少ない。「マイクロサービスでもないのに JWT」は過剰設計になりがち。


練習課題

mkdir -p ~/learn/security/day-jwt
cd ~/learn/security/day-jwt
  1. main.go を上記の Go コードで作成し、JWT_SECRET=$(openssl rand -base64 32) go run main.go でトークン発行・検証が動くか確認
  2. alg=none の偽トークンを Python で作って verifyToken に渡し、拒否されることを確認
  3. payload の exp を過去にして、検証が token is expired で失敗することを確認
  4. シークレットを環境変数から読むようにし、未設定時はエラーで落ちるよう改造
  5. HS256 → ES256 に切り替え(鍵ペアを openssl ecparam で生成)
# ES256 用鍵ペア生成
openssl ecparam -genkey -name prime256v1 -noout -out private.pem
openssl ec -in private.pem -pubout -out public.pem

締め: git で証跡を残す

cd ~/learn/security/day-jwt
git init
git add main.go go.mod go.sum
# 鍵ファイルは絶対に commit しない
echo "*.pem" > .gitignore
echo ".env" >> .gitignore
git add .gitignore
git commit -m "feat(security): JWT 署名・検証と alg=none 拒否を実装"

チェックリスト

  • JWT の3ピース構造と Base64URL の意味を説明できる
  • HS256 / RS256 / ES256 の使い分けを言える
  • alg=none 脆弱性が何で、どう防ぐかを説明できる
  • golang-jwt v5 で WithValidMethods を必ず付ける癖がついた
  • payload に入れていいもの・ダメなものを判断できる
  • アクセストークンとリフレッシュトークンの役割分担を理解した
  • JWT の失効が困難な理由と、4つの対策を言える
  • localStorage に JWT を保存してはいけない理由を XSS と絡めて説明できる

詰まった時のチートシート

やりたいことコード / コマンド
JWT をデコードして中身を見るjwt.io に貼る or echo $JWT | cut -d. -f2 | base64 -d
Go で HS256 署名jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
検証で alg を縛るjwt.WithValidMethods([]string{"HS256"})
ES256 鍵生成openssl ecparam -genkey -name prime256v1
失効チェックjti を Redis ブラックリストに登録
公開鍵を配布JWKS エンドポイント /.well-known/jwks.json
32バイトランダムシークレットopenssl rand -base64 32

「実務OK」基準

  • alg=none と alg 偽装の両方を即座にコードで防げる
  • JWT を選ぶか伝統的セッションを選ぶか、要件から判断できる
  • 失効戦略(短命/ブラックリスト/バージョン)を要件に合わせて選べる
  • 鍵ローテーション(kid + JWKS)の流れをホワイトボードで描ける
  • payload に何を入れていいかの判断が速い

さらに深掘りするなら

  • RFC 7519 (JWT), RFC 7515 (JWS), RFC 7517 (JWK) - 原典
  • 「OAuth 2 in Action」 (Manning) - OAuth/OIDC の本質
  • github.com/golang-jwt/jwt v5 のソース - Parse 周りを読むと「許可 alg リスト」の実装が分かる
  • Auth0 のブログ「Critical vulnerabilities in JSON Web Token libraries」 - alg=none 史上の原典記事
  • CVE-2022-21449 (Java ECDSA) の解説記事 - 「全ゼロ署名」が通った理由

メタ認知: 自分の理解度を診断する

30秒セルフテスト

  1. JWT の3ピースを 左から 順に
  2. alg=none バイパスを 1文 で説明
  3. HS256 と RS256 の使い分けは?
  4. JWT の失効が難しい理由は? どう運用で対処する?
  5. JWT を どこに保存すべきでない か(XSS の文脈で)

詰まったら → セッション①の3ピース構造、セッション②の脆弱性カタログを再読。

次のレッスン

2-2 セッション管理 で、JWT と対比される伝統的セッション管理(Cookie、HttpOnly、SameSite、セッションストア)を扱う。JWT を学んだ直後にセッションを学ぶ ことで、両者の差分が立体的に見える。

間隔反復ポイント

JWT の alg=none 系の話は 半年で忘れる。1ヶ月後、3ヶ月後にこの章の「脆弱性カタログ」だけ眺める習慣を作ると、コードレビューで瞬発力が出る。