0
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?

42日でバックエンドエンジニアの基礎を完全に理解する #26 - GoでJWTを発行・検証する

0
Posted at

この記事で学ぶこと

前回の #25 で、認証は入口で1回、認可は毎リクエストと学びました。すると疑問が残ります。入口で1回認証した後、2回目以降のリクエストでは「私はさっき認証を通った者です」をどう伝えるのか。その答えの代表がトークンで、事実上の標準が JWT(JSON Web Token)です。

#1 で JWT を解剖したときは、出来上がったトークンを外から眺めました。今回は作る側に回ります。前回の宿題も回収します。base64 は誰でも読めるのに、なぜ改ざんは検知できるのか。ライブラリなしの手組みで一度 JWT を作ると、この疑問は一発で解けます。

この記事を終えると以下ができるようになります。

  • JWT が「誰でも読めるのに改ざんは検知できる」理由を、署名の計算から人に説明できる
  • golang-jwt ライブラリでトークンの発行と検証を実装できる
  • 改ざん・期限切れ・alg=none のトークンがそれぞれどう拒否されるかを、実測で知っている

この回はGo編です。Pythonで進めている方は「#26 - PythonでJWTを発行・検証する」をご覧ください。

見出しの 🔴🟡⚪ は学習の優先度です。🔴は確実に覚える、🟡は理屈を理解する、⚪は今は流してOK。迷ったら🔴だけ確実に押さえてください。


🔴 JWTのおさらい、3つのパート

JWT はドットで区切られた3つのパートでできています。#1 の復習です。

<ヘッダ>.<ペイロード>.<署名>
パート 中身 誰が読めるか
ヘッダ 署名アルゴリズム(alg)など 誰でも
ペイロード クレーム(ユーザーIDや有効期限) 誰でも
署名 ヘッダとペイロードから計算した検証用データ 誰でも見えるが、作れるのは秘密鍵の持ち主だけ

ペイロードに入れる情報をクレーム(claim)と呼びます。名前は自由に決められますが、よく使うものには標準の名前があります。まずこの3つを覚えてください。

クレーム 読み 意味
sub subject 誰のトークンか。ユーザーIDを入れる
iat issued at いつ発行したか(Unix時刻)
exp expiration いつまで有効か(Unix時刻)

⚪ 他にも発行者を表す iss、宛先を表す aud などがありますが、必要になったときに調べれば十分です。

3つのパートのうち、ヘッダとペイロードはただの base64url エンコード(URLで使える形の base64)です。#25 で実測したとおり、base64 は暗号化ではないので誰でも復元できます。つまり JWT が守っているのは機密性ではありません。守っているのは**完全性、つまり「書き換えられていないこと」**です。その仕事を担うのが3つ目のパート、署名です。

署名の正体は、概念の説明を読むより自分で計算した方が早いので、さっそく手を動かします。


【手を動かす①】ライブラリなしでJWTを作る

作業ディレクトリを作ります。

mkdir jwt-practice && cd jwt-practice
go mod init jwt-practice
mkdir handmade

今回は main 関数を持つプログラムを4つ書くので、ステップごとに handmade のようなディレクトリを切って main.go を置き、go run ./handmade の形で実行していきます。

まず handmade/main.go を作ります。標準ライブラリだけで JWT を組み立てるコードです。

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
)

func main() {
	secret := []byte("dev-secret-change-me")

	// ① ヘッダとペイロードをJSONで作る
	headerJSON, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
	payloadJSON, _ := json.Marshal(map[string]any{"sub": "42", "name": "田中", "exp": 1800000000})

	// ② base64url でエンコードして . で繋ぐ
	enc := base64.RawURLEncoding
	signingInput := enc.EncodeToString(headerJSON) + "." + enc.EncodeToString(payloadJSON)

	// ③ 「ヘッダ.ペイロード」全体を秘密鍵でHMAC-SHA256にかける
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(signingInput))
	signature := enc.EncodeToString(mac.Sum(nil))

	fmt.Println(signingInput + "." + signature)
}

やっていることは3行で言えます。ヘッダとペイロードを JSON にする。base64url にしてドットで繋ぐ。その文字列全体を、秘密鍵を混ぜたハッシュ関数(HMAC-SHA256)にかけて署名にする。これだけです。

exp1800000000 は 2027年1月にあたる固定の Unix 時刻です(学習用にわざと固定しています)。実行します。

go run ./handmade
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE4MDAwMDAwMDAsIm5hbWUiOiLnlLDkuK0iLCJzdWIiOiI0MiJ9.gw2aM2Zt2XjGQqZpPN12UlZ__89Vqh1PdVOFCO_TgLo

本物の JWT ができました。入力をすべて固定しているので、皆さんの手元でも1文字違わず同じ出力になるはずです(Go の json.Marshal はマップのキーをアルファベット順に並べるため、JSONの形も毎回同じになります)。

ペイロードを覗く

このトークンの2つ目のパートを、#25 でやったとおり base64 で復元してみます。

echo 'eyJleHAiOjE4MDAwMDAwMDAsIm5hbWUiOiLnlLDkuK0iLCJzdWIiOiI0MiJ9' | base64 -d
{"exp":1800000000,"name":"田中","sub":"42"}

中身が丸見えです。秘密鍵を知らなくても、トークンを手に入れた人は誰でもここまで読めます。ここに個人情報や機密を入れてはいけない理由が、この1行で体感できます。

🔴 改ざんしてみる

では攻撃者になりきって、このトークンの sub"42" から "1" に書き換えてみます。ペイロードは base64 を作り直すだけなので、書き換え自体は誰でもできます。問題は署名です。検証する側のコードと一緒に確かめます。tamper/main.go を作ります。

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"strings"
)

// verify は「ヘッダ.ペイロード」から署名を作り直し、3つ目のパートと比較する
func verify(token string, secret []byte) bool {
	parts := strings.Split(token, ".")
	if len(parts) != 3 {
		return false
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(parts[0] + "." + parts[1]))
	expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(parts[2]))
}

func main() {
	secret := []byte("dev-secret-change-me")
	token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE4MDAwMDAwMDAsIm5hbWUiOiLnlLDkuK0iLCJzdWIiOiI0MiJ9.gw2aM2Zt2XjGQqZpPN12UlZ__89Vqh1PdVOFCO_TgLo"

	fmt.Println("本物のトークン:", verify(token, secret))

	// 攻撃者になりきって、ペイロードの sub を "42" → "1" に書き換える
	enc := base64.RawURLEncoding
	forgedPayload := enc.EncodeToString([]byte(`{"exp":1800000000,"name":"田中","sub":"1"}`))
	parts := strings.Split(token, ".")
	forged := parts[0] + "." + forgedPayload + "." + parts[2]

	fmt.Println("改ざんしたトークン:", verify(forged, secret))
}

検証の手順に注目してください。届いたトークンの「ヘッダ.ペイロード」から、自分の秘密鍵で署名をもう一度計算し、届いた署名と一致するか比べています。照合であって復号ではありません。実行します。

go run ./tamper
本物のトークン: true
改ざんしたトークン: false

改ざん版は署名が合いません。なぜか。署名は「ヘッダ.ペイロード」の全体から計算されるので、ペイロードを1文字でも変えると正しい署名は別の値になります。攻撃者が偽ペイロードに合う署名を作り直そうにも、計算には秘密鍵が要ります。秘密鍵はサーバーの中にしかありません。

これが前回の宿題の答えです。「読める」と「改ざんできる」は別の性質で、JWT は前者を諦め、後者だけを署名で防いでいます。手紙の中身は読めるが、本人の印章がなければ発行できない、という構図です。

⚪ 比較に == ではなく hmac.Equal を使っているのは、比較にかかる時間の差から署名の情報が漏れるのを防ぐためです(タイミング攻撃と呼びます)。今は「署名の比較には専用関数を使う」とだけ覚えて流してください。


【手を動かす②】golang-jwtで発行する

原理がわかったので、実務で使う形に移ります。Go で定番のライブラリは golang-jwt です。

go get github.com/golang-jwt/jwt/v5
go: added github.com/golang-jwt/jwt/v5 v5.3.1

🟡 手組みできたのになぜライブラリを使うのか。①のコードには、期限切れの判定も、アルゴリズムの確認も、クレームの型チェックもありません。JWT の脆弱性の歴史は、こうした「書き忘れると穴になる処理」の書き忘れの歴史です(後述する alg=none 問題が代表です)。枯れたライブラリに背負わせるのが定石で、自前実装は学習用と割り切ってください。

issue/main.go を作ります。

package main

import (
	"fmt"
	"log"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

func main() {
	secret := []byte("dev-secret-change-me")

	now := time.Now()
	claims := jwt.MapClaims{
		"sub": "42",                             // subject: 誰のトークンか(ユーザーID)
		"iat": now.Unix(),                       // issued at: 発行時刻
		"exp": now.Add(15 * time.Minute).Unix(), // expiration: 有効期限
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	signed, err := token.SignedString(secret)
	if err != nil {
		log.Fatal("署名に失敗:", err)
	}
	fmt.Println(signed)
}

NewWithClaims に署名アルゴリズムとクレームを渡し、SignedString に秘密鍵を渡すと、①で手組みした3パート構造を全部組み立ててくれます。実行します。

go run ./issue
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODg1ODg3OTAsImlhdCI6MTc4ODU4Nzg5MCwic3ViIjoiNDIifQ.Ake9PhyqM-XOT_v0Z1zXqge0WFuX1ozT6cbfmCebwus

今度は iatexp が実行時刻から決まるので、皆さんの手元では別のトークンになります。実際のログインAPIでは、パスワード検証(認証)が通った直後にこの発行処理を呼び、レスポンスでクライアントに渡します。有効期限を15分と短めにしている理由は後述します。


【手を動かす③】検証する、そして壊す

受け取る側です。verify/main.go を作ります。コマンドライン引数でトークンを受け取って検証します。

package main

import (
	"fmt"
	"os"

	"github.com/golang-jwt/jwt/v5"
)

func main() {
	secret := []byte("dev-secret-change-me")
	tokenString := os.Args[1]

	token, err := jwt.Parse(tokenString,
		func(t *jwt.Token) (any, error) {
			return secret, nil
		},
		jwt.WithValidMethods([]string{"HS256"}),
	)
	if err != nil {
		fmt.Println("検証NG:", err)
		return
	}

	claims := token.Claims.(jwt.MapClaims)
	fmt.Println("検証OK sub =", claims["sub"])
}

🟡 2つ、形に理由があります。

1つ目、鍵を直接渡さず「鍵を返す関数」を渡しています。keyfunc と呼ばれる設計で、実運用では鍵を定期的に入れ替えたり複数の鍵を併用したりするため、トークンのヘッダを見てから使う鍵を選べるようにこうなっています。今回は鍵が1本なので、無条件に返すだけです。

2つ目、WithValidMethods で受け入れるアルゴリズムを明示しています。ヘッダの alg は検証前の、つまり攻撃者が自由に書ける入力です。「ヘッダに書いてあるアルゴリズムで検証する」を素直にやると、攻撃者にアルゴリズムを選ばせることになります。自分が発行時に使ったものだけを許可リストで固定するのが定石です。

では、正常なトークンと壊れたトークンを順に食わせます。②で発行したトークンをそのまま渡すと、

go run ./verify "$(go run ./issue)"
検証OK sub = 42

通りました。次に壊します。①の要領で sub"1" に書き換えたトークンを渡すと、

検証NG: token signature is invalid: signature is invalid

署名の不一致で拒否されました。次は exp を15分前に設定して発行した、期限切れのトークンです。

検証NG: token has invalid claims: token is expired

注目してほしいのは、期限切れ判定のコードを1行も書いていないことです。jwt.Parse は署名の照合に加えて、exp と現在時刻の比較まで自動でやります。自前で書き忘れる余地がありません。これがライブラリを使う理由の具体例です。

最後に、ヘッダを {"alg":"none","typ":"JWT"} にして署名部分を空にしたトークンです。none は「署名なし」を表す仕様上正規の値で、これを受け入れてしまった実装が過去に大量のなりすましを許しました。

検証NG: token signature is invalid: signing method none is invalid

golang-jwt v5 は既定で拒否しますが、WithValidMethods を書いておけば「HS256 以外はすべて拒否」がコード上も明示され、レビューでも一目でわかります。アルゴリズムを混同させる攻撃の実践は #29 でやります。


実務での判断基準

🔴 秘密鍵の扱いがすべて

ここまでの実験でわかるとおり、JWT の安全性は秘密鍵の一点に懸かっています。もし HS256 の秘密鍵が漏れると、攻撃者は好きな sub で正規の署名付きトークンを発行できます。つまり全ユーザーへのなりすましです。パスワードの漏洩は漏れたユーザーだけの被害ですが、署名鍵の漏洩はサービス全体の認証の崩壊です。

だから鍵をコードに書きません。この記事の dev-secret-change-me はあくまで学習用で、実務では os.Getenv("JWT_SECRET") のように環境変数やシークレット管理サービスから読み込みます。値は32バイト以上のランダム値にします。短い英単語のような鍵は総当たりで現実的に割れます(これも #29 で実際に割ってみます)。

🔴 ペイロードに機密を入れない

①で見たとおり、ペイロードはトークンを持つ人全員が読めます。メールアドレス、住所、内部用のフラグなどを入れてはいけません。入れるのはユーザーID(sub)と、必要なら表示名やロール程度に絞ります。「署名があるから安全」という言葉の「安全」は改ざんに対してであって、盗み見に対してではありません。

🔴 exp は短く。JWTは途中で失効させられない

JWT はステートレスです。サーバーは発行したトークンの一覧を持たず、届いた署名と exp だけで判定します。裏を返すと、一度発行したトークンを期限前に無効化する仕組みが標準にはありません。ユーザーがログアウトしても、トークンが盗まれたとわかっても、exp が来るまでそのトークンは有効です。

なので有効期限を短く、15分から1時間程度にして、被害の時間窓を絞ります。「短いと再ログインが面倒では」への答えがリフレッシュトークンですが、これは #30 で扱います。

🟡 HS256とRS256の使い分け

今回使った HS256 は共通鍵方式で、発行と検証が同じ鍵です。もう1つの主流に RS256(公開鍵方式)があります。

HS256 RS256
発行に使う鍵 共通の秘密鍵 秘密鍵
検証に使う鍵 同じ秘密鍵 公開鍵(配ってよい)
向く場面 発行者と検証者が同じアプリ 検証者が多数・社外

判断基準は「検証する人に発行能力を持たせてよいか」です。HS256 では検証できる人は発行もできてしまいます。単一のアプリが自分で発行して自分で検証するなら HS256 で十分。マイクロサービスや外部サービスなど検証者が増えるなら、公開鍵を配るだけで済む RS256 を選びます。Google などの ID プロバイダが RS256 系なのはこのためで、公開鍵の配布の仕組み(JWKS)は #30 で登場します。


確認テスト

知識問題

Q1. JWT は「誰でも読めるのに改ざんできない」と言われます。署名の計算方法に触れながら、この2つが両立する仕組みを説明してください。

Q2. 同僚が「ログアウトAPIを実装したので、ログアウトしたユーザーのJWTは使えなくなります」と言っています。JWT の性質を踏まえて、確認すべき点を指摘してください。

Q3. 検証コードに jwt.WithValidMethods([]string{"HS256"}) を書く理由を、ヘッダの alg の信頼性の観点から説明してください。

Q4. HS256 の秘密鍵が漏洩した場合に起きることを説明し、なぜ1ユーザーのパスワード漏洩より深刻なのかを答えてください。

実技問題

Q5. 次の2つの関数を実装してください。次回の認証ミドルウェアでそのまま部品として使います。

  • IssueToken(secret []byte, userID string, ttl time.Duration) (string, error) … userID のトークンを有効期限 ttl で発行する
  • ParseUserID(secret []byte, tokenString string) (string, error) … トークンを検証し、通ればユーザーIDを返す

さらに、ttl に負の値を渡して期限切れトークンを作り、ParseUserID が拒否することを実際に確認してください。


確認テスト 解答・解説

Q1

ヘッダとペイロードは base64url エンコードしただけなので、誰でも元の JSON に復元できます。読める理由はこれです。

一方、3つ目のパートの署名は「ヘッダ.ペイロード」の全体と秘密鍵から HMAC-SHA256 で計算します。検証側は届いたヘッダとペイロードから署名を再計算し、届いた署名と一致するかを照合します。ペイロードを1文字でも書き換えると再計算した署名が合わなくなり、攻撃者が辻褄の合う署名を作り直すには秘密鍵が必要です。読めることと改ざんできないことが両立するのは、JWT が守っているのが機密性ではなく完全性だからです。ここまで言えれば完璧です。

Q2

「サーバー側でトークンを無効化する仕組みを別途作ったのか」を確認すべきです。

JWT はステートレスで、検証は署名と exp だけで完結します。サーバーが発行済みトークンの一覧を持っていない以上、ログアウトAPIがサーバー側のセッションを消しても、発行済みのトークン自体は exp まで署名の検証を通り続けます。クライアントがトークンを捨てるだけでは、盗まれていた場合に効きません。本当に即時失効させたいなら、無効化したトークンの一覧をサーバーに持つなどの追加の仕組みが必要で、それは「ステートレス」という JWT の利点を一部手放すことを意味します。

Q3

ヘッダの alg は署名の検証前に読む値、つまり攻撃者が自由に書き換えられる入力です。「ヘッダに書かれたアルゴリズムに従って検証する」実装だと、検証方法の選択権を攻撃者に渡すことになります。alg: none(署名なし)を差し込まれたり、別のアルゴリズムに誘導されたりする攻撃の入口がここです。

WithValidMethods は、発行時に自分が使ったアルゴリズムだけの許可リストで、それ以外を機械的に拒否します。実測では alg=none のトークンに signing method none is invalid が返りました。「攻撃者が書ける場所の値を判断に使わない」という原則の適用例として覚えておくと、JWT 以外でも使えます。

Q4

攻撃者はその鍵で、任意の sub・任意の exp を持つ正規の署名付きトークンを自由に発行できます。サーバーの検証は署名照合なので、これらを本物と区別できません。つまり全ユーザーになりすませます。

パスワード漏洩は漏れた本人のアカウントに被害が閉じますが、署名鍵はサービス全体の認証を支える1点なので、漏れた瞬間に認証システム全体が崩壊します。だから鍵はコードに書かず、環境変数やシークレット管理から読み、十分に長いランダム値にします。漏洩が疑われたら鍵を交換します(このとき発行済みトークンが全部無効になるのは、Q2 の性質の裏返しです)。

Q5

package main

import (
	"fmt"
	"log"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// IssueToken は userID のためのトークンを ttl の有効期限つきで発行する
func IssueToken(secret []byte, userID string, ttl time.Duration) (string, error) {
	now := time.Now()
	claims := jwt.MapClaims{
		"sub": userID,
		"iat": now.Unix(),
		"exp": now.Add(ttl).Unix(),
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	signed, err := token.SignedString(secret)
	if err != nil {
		return "", fmt.Errorf("トークンの署名に失敗しました: %w", err)
	}
	return signed, nil
}

// ParseUserID はトークンを検証し、通ればユーザーIDを返す
func ParseUserID(secret []byte, tokenString string) (string, error) {
	token, err := jwt.Parse(tokenString,
		func(t *jwt.Token) (any, error) { return secret, nil },
		jwt.WithValidMethods([]string{"HS256"}),
	)
	if err != nil {
		return "", fmt.Errorf("トークンの検証に失敗しました: %w", err)
	}
	sub, err := token.Claims.GetSubject()
	if err != nil {
		return "", fmt.Errorf("sub クレームの取得に失敗しました: %w", err)
	}
	return sub, nil
}

func main() {
	secret := []byte("dev-secret-change-me")

	signed, err := IssueToken(secret, "42", 15*time.Minute)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("発行:", signed)

	userID, err := ParseUserID(secret, signed)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("検証OK userID =", userID)

	// 期限切れを実際に起こす(-1分のttlで発行する)
	expired, _ := IssueToken(secret, "42", -1*time.Minute)
	if _, err := ParseUserID(secret, expired); err != nil {
		fmt.Println("期限切れ:", err)
	}
}

実行結果です。

発行: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODg1ODg4OTIsImlhdCI6MTc4ODU4Nzk5Miwic3ViIjoiNDIifQ.No1Z9-tfoltkBaazIdUbRqiO800whGJiFRrTWEQcczo
検証OK userID = 42
期限切れ: トークンの検証に失敗しました: token has invalid claims: token is expired

ポイント

  • 発行と検証で同じ secret を使う。アルゴリズムも発行(SigningMethodHS256)と検証(WithValidMethods)で揃える
  • sub の取り出しに GetSubject() を使うと、クレームが無い・型が違う場合もエラーとして拾える
  • エラーは #11 で学んだ %w でラップし、呼び出し元が原因を判別できる形で返す
  • 期限切れ判定は jwt.Parse に任せる。自前の時刻比較を書かないことが、書き忘れの余地をなくす

この2つの関数が、次回作る認証ミドルウェアの中身になります。


まとめ

  • JWT は「ヘッダ.ペイロード.署名」の3パート。ペイロードは誰でも読める。守っているのは機密性ではなく完全性
  • 署名は「ヘッダ.ペイロード」全体と秘密鍵から計算した HMAC。1文字の改ざんで一致しなくなり、作り直しには秘密鍵が要る
  • 発行は NewWithClaims + SignedString、検証は Parse + keyfunc + WithValidMethods。期限切れの判定はライブラリがやる
  • 秘密鍵の漏洩は全ユーザーへのなりすまし。鍵は環境変数から読み、ペイロードに機密を入れず、exp は短くする
  • JWT は途中で失効させられない。だからこそ有効期限を短くして被害の時間窓を絞る

次回は #27 認証ミドルウェア です。今回の検証処理を、全エンドポイントに一括でかける仕組みを作ります。#25 で「認証は一括で守れるが、認可はエンドポイントごとに書く」と学びました。その「一括で守る」の実装が次回のテーマです。

0
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
0
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?