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 などで報告- 攻撃者は
payloadのsubをadminに書き換え、署名なしで送るだけで管理者になれた防御:
- 検証時に許可アルゴリズムをホワイトリスト化(
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 を「ログアウト即無効」にする方法は本質的に難しい。選択肢:
- 短命設計(5-15分): 失効を諦めて、影響を時間で限定する(最も多い)
- ブラックリスト方式: 失効した
jtiを Redis に入れて、検証時に毎回見る - ホワイトリスト方式: 有効な
jtiを DB に持つ(実質セッション、JWT である意味が薄れる) - バージョン番号方式: ユーザーごとに
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-jwtmain.goを上記の Go コードで作成し、JWT_SECRET=$(openssl rand -base64 32) go run main.goでトークン発行・検証が動くか確認alg=noneの偽トークンを Python で作ってverifyTokenに渡し、拒否されることを確認- payload の
expを過去にして、検証がtoken is expiredで失敗することを確認 - シークレットを環境変数から読むようにし、未設定時はエラーで落ちるよう改造
- 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/jwtv5 のソース -Parse周りを読むと「許可 alg リスト」の実装が分かる- Auth0 のブログ「Critical vulnerabilities in JSON Web Token libraries」 - alg=none 史上の原典記事
- CVE-2022-21449 (Java ECDSA) の解説記事 - 「全ゼロ署名」が通った理由
メタ認知: 自分の理解度を診断する
30秒セルフテスト
- JWT の3ピースを 左から 順に
alg=noneバイパスを 1文 で説明- HS256 と RS256 の使い分けは?
- JWT の失効が難しい理由は? どう運用で対処する?
- JWT を どこに保存すべきでない か(XSS の文脈で)
詰まったら → セッション①の3ピース構造、セッション②の脆弱性カタログを再読。
次のレッスン
2-2 セッション管理 で、JWT と対比される伝統的セッション管理(Cookie、HttpOnly、SameSite、セッションストア)を扱う。JWT を学んだ直後にセッションを学ぶ ことで、両者の差分が立体的に見える。
間隔反復ポイント
JWT の
alg=none系の話は 半年で忘れる。1ヶ月後、3ヶ月後にこの章の「脆弱性カタログ」だけ眺める習慣を作ると、コードレビューで瞬発力が出る。