Skip to content

認証と認可

実装: crypto/jwt/ / 実行: go test ./crypto/jwt/

ログインの裏側。まず「認証(あなたは誰)」と「認可(何をしてよい)」という似て非なる2つを区別する。次に、ログイン状態を覚える2つの方式、すなわちサーバが覚えるセッション方式と、サーバが覚えなくてよい JWT 方式を作る。JWT の「サーバが署名した通行証」がなぜ改竄できないのか、ここまでに作った鍵付きの指紋がそのまま効いてくる。

この章で作るもの

「ログインしている状態」をどう保つか。2つの方式を作って比べる。サーバが覚えるか、署名で証明させるかの二択になる。

順に見ていく。

  1. 認証と認可は別物: 誰かを確かめることと、何をしてよいかを決めることは順番が違う
  2. サーバが覚える(セッション + Cookie): 対応表を持つ。Cookie に何の印を付けるかで攻撃面が変わる
  3. サーバが覚えない(JWT): 署名した通行証を渡す。守っているのは秘密ではなく改竄されないこと
  4. 検証側がアルゴリズムを決める: 相手が渡してきた値で、相手の検証方法を決めてはいけない
  5. パスワードを預からない(OAuth2 の入口): 3役に分けて、自分はパスワードを見ない

① 認証と認可は別物

  • 認証(Authentication): 「あなたは誰か」を確かめる。ログイン(ID/パスワード)がこれ
  • 認可(Authorization): 「あなたは何をしてよいか」を決める。管理者だけ削除できる、など

順番は認証 → 認可。まず誰かを確かめ、その人の権限を見る。この2つを混同すると 「ログインさえすれば何でもできる」ような穴が生まれる。JWT は認証の結果(誰か)を 運ぶ通行証で、その中に認可の情報(権限)を載せることもある。

ログインID/PW を送る
サーバセッションIDを発行し保存
Cookieブラウザが保持
以降の要求Cookie で本人確認
セッション方式。サーバは発行したセッションIDと『誰か』の対応表を持つ。Cookie はそのIDを運ぶだけ

ログインに成功すると、サーバはランダムなセッションIDを作り、 「このID = user-42」という対応をサーバ側に保存する。そのIDを Cookie でブラウザに渡し、 以降のリクエストにはブラウザが自動で Cookie を付ける。サーバは Cookie のIDから 対応表を引いて「誰か」を知る。

素直だが弱点がある。サーバが状態を持つことだ。サーバが複数台なら対応表を共有 (Redis 等)しないといけないし、リクエストのたびに対応表を引く。

Cookie は自動で付いて飛ぶ。便利さの正体がそのまま危うさの正体でもあるので、何を付けるかで守る相手が変わる。

何をするか何から守るか
HttpOnlyJavaScript から読めなくするページに入り込んだスクリプトに盗まれること
SecureHTTPS のときだけ送る経路で盗み見られること
SameSite別サイトから来た要求では付けない別サイトから勝手に送られる要求
Domain / Pathどの範囲へ送るか決める送る必要のない相手に届くこと

上2つと下2つで役割が違う。HttpOnlySecure盗まれないためSameSite勝手に使われないための印になる。盗まれることと勝手に使われることは別の攻撃なので、片方の印だけでは片方しか塞がらない。

そして置き場所そのものが選択になる。ログイン状態を JavaScript から読める場所(localStorage など)に置くと、ページに入り込んだスクリプトに読まれる。HttpOnly の Cookie に置くと読まれなくなるが、今度は自動で付いて飛ぶので、別サイトからの要求に乗る。どちらにも穴があり、置き場所を選ぶことは攻撃の種類を選ぶことになる。詳しくは送れるのに読めない送れることを使う攻撃で測った。

③ サーバが覚えない(JWT)

ログインID/PW を送る
サーバ署名付きトークンを発行
クライアントトークンを保持
以降の要求トークンの署名を検証
JWT 方式。サーバは『誰か』を書いた紙に署名して渡す。以降サーバは対応表を持たず、署名を検証するだけ

JWT の発想は逆転している。サーバは「この人は user-42 です」と紙に書いて署名し、 その紙(トークン)をクライアントに渡す。以降サーバは何も覚えない。トークンを見せられたら、 署名を検証するだけで「確かに自分が発行した、改竄されていない」と分かる。 対応表が要らない(ステートレス)ので、サーバが何台でもスケールする。

JWT の中身: header.payload.signature

JWT は3つのパートをドットで繋いだ文字列。

go
// Claims はトークンに載せる主張(誰が、いつまで有効か)。
type Claims struct {
	Sub string `json:"sub"` // subject: 誰のトークンか
	Exp int64  `json:"exp"` // expiration: 有効期限(Unix 秒)
}

type header struct {
	Alg string `json:"alg"`
	Typ string `json:"typ"`
}
  • header: 署名アルゴリズム(HS256 など)
  • payload: 主張(sub = 誰か、exp = 期限)。base64 なだけで暗号化ではない。誰でも読める
  • signature: header + payload に秘密鍵で HMAC をかけたもの

重要な誤解ポイント: payload は暗号化されていない。base64 を戻せば中身は丸見え。 JWT が守るのは秘密ではなく改竄されないこと。だから JWT にパスワードのような 秘密は入れてはいけない。

go
// encodeSegment は base64url(パディングなし)で符号化する。JWT の各パートの形式。
func encodeSegment(b []byte) string {
	return base64.RawURLEncoding.EncodeToString(b)
}

// sign は "header.payload" に HMAC-SHA256 をかけた署名を base64url で返す。
// HMAC = 秘密鍵とメッセージからハッシュを作る仕組み。鍵を知らないと同じ値を作れない。
func sign(signingInput string, secret []byte) string {
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(signingInput))
	return encodeSegment(mac.Sum(nil))
}

// Sign は Claims を JWT 文字列 "header.payload.signature" にする。
func Sign(claims Claims, secret []byte) (string, error) {
	h, err := json.Marshal(header{Alg: "HS256", Typ: "JWT"})
	if err != nil {
		return "", err
	}
	p, err := json.Marshal(claims)
	if err != nil {
		return "", err
	}
	signingInput := encodeSegment(h) + "." + encodeSegment(p)
	return signingInput + "." + sign(signingInput, secret), nil
}

なぜ改竄できないのか

署名は HMAC-SHA256(header.payload, 秘密鍵)秘密鍵を知らないと、この署名を 作れない。攻撃者が payload を「sub: admin」に書き換えても、それに合う署名は 秘密鍵なしには計算できない。サーバは受け取ったトークンの header+payload から 署名を計算し直し、トークンの署名と一致するかを見る。

go
// Verify はトークンを検証し、正しければ Claims を返す。
// チェックは3段: (1)形式 (2)署名が一致するか (3)期限切れでないか。
func Verify(token string, secret []byte) (Claims, error) {
	parts := strings.Split(token, ".")
	if len(parts) != 3 {
		return Claims{}, fmt.Errorf("jwt: token must have 3 parts, got %d", len(parts))
	}
	signingInput := parts[0] + "." + parts[1]

	// (2) 署名検証。自分で計算した署名と、トークンの署名を突き合わせる。
	// タイミング攻撃を避けるため hmac.Equal(定数時間比較)を使う。
	// これが「自分が発行した、改竄されていない」の保証。alg=none 攻撃も、
	// トークンの alg を読まず HS256 で計算するのでここで弾かれる。
	expected := sign(signingInput, secret)
	if !hmac.Equal([]byte(expected), []byte(parts[2])) {
		return Claims{}, errors.New("jwt: signature mismatch")
	}

	// (1') ペイロードを復元。
	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil {
		return Claims{}, fmt.Errorf("jwt: bad payload encoding: %w", err)
	}
	var claims Claims
	if err := json.Unmarshal(payload, &claims); err != nil {
		return Claims{}, fmt.Errorf("jwt: bad payload json: %w", err)
	}

	// (3) 期限チェック。
	if claims.Exp != 0 && time.Now().Unix() >= claims.Exp {
		return Claims{}, errors.New("jwt: token expired")
	}
	return claims, nil
}

動かす

下のデモで payload の sub を書き換えると、サーバが計算し直した署名がトークンの署名と食い違い、改竄が検出される。秘密鍵を当てられない限り、攻撃者は正しい署名を作れない。

デモJWT の署名と改竄検出HS256

サーバが発行する正規のトークン

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwiZXhwIjo5OTk5OTk5OTk5fQ.R9oVsQ

header アルゴリズム payload sub=user-42(誰でも読める) signature 秘密鍵で計算(サーバだけ作れる)

攻撃: ペイロードを書き換えて権限を奪おうとする

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6OTk5OTk5OTk5OX0.R9oVsQ

サーバが検証: 受け取ったヘッダ+ペイロードから署名を計算し直す

計算した署名 mKrGFgトークンの署名 R9oVsQ

✗ 不一致 → 改竄を検出、リクエスト拒否

④ 検証側がアルゴリズムを決める

JWT には有名な攻撃がある。ヘッダの algnone に書き換えて「署名なし」を 主張する攻撃だ。実装がヘッダの alg を鵜呑みにして「none なら検証しない」とすると、 誰でも好きな payload のトークンを作れてしまう。Verify はトークンの alg を読まず、 常に HS256 で署名を計算するので、この攻撃は署名不一致で弾かれる。 **「検証側がアルゴリズムを固定する」**のが鉄則。

⑤ パスワードを預からない(OAuth2 の入口)

「Google でログイン」のような、自分のサーバがパスワードを預からずに認証する 仕組みが OAuth2 になる。ここでは入口だけを示し、流れの中身は次章の OAuth と認可フローで作る。登場人物は3役:

  • クライアント(あなたのアプリ)
  • 認可サーバ(Google。ユーザーを認証してトークンを発行する)
  • リソースサーバ(Google のAPI。トークンを見てデータを渡す)

ユーザーは Google にだけパスワードを渡し、あなたのアプリは Google が発行した トークン(中身はしばしば JWT)を受け取る。あなたのアプリはパスワードを一切見ない。 この3役の間でトークンをやり取りする流れが OAuth2 で、その通行証の信頼性を 支えているのが、この章で作った署名。

設計の観点

  • 覚えるか、証明させるか: ログイン状態の保ち方は、サーバが対応表を持つか、持たずに署名で確かめるかの二択になる。状態をどちらが持つかが、そのままスケールと失効のしやすさを裏返しに決める
  • 守っているのは秘密ではなく、改竄されないこと: JWT の中身は誰でも読める。混同すると、読まれて困るものを入れてしまう
  • 検証側がアルゴリズムを決める: 受け取ったトークンの自己申告を信じると、algnone に書き換えられて終わる。相手が渡してきた値で、相手を検証する方法を決めてはいけない
  • 発行と検証を分けられるか: 共通鍵だと検証できる者は発行もできる。公開鍵署名にすると「検証は誰でも、発行はサーバだけ」に分かれ、検証を外へ配れるようになる
  • 失効は後から足せない: 「サーバが覚えない」と決めた時点で、発行済みを取り消す手段を失う。有効期限を短くする、失効リストを別に持つ、といった対処はすべてこの決定の埋め合わせになる
  • Cookie の印は、守る相手ごとに選ぶ: 盗まれないための印と、勝手に使われないための印は別物になる
  • 認証と認可を混ぜない: 誰かを確かめることと、何をしてよいかを決めることは別。1つのトークンに両方を載せられるからこそ、区別を保つ必要がある

対照と実例

セッションJWT
サーバの状態持つ(対応表)持たない(ステートレス)
スケール対応表の共有が要る楽(署名検証だけ)
失効即座(表から消す)難しい(発行済みは期限まで有効)
中身サーバ内(見えない)クライアントが持つ(base64 で読める)

JWT の泣き所は失効の難しさになる。一度発行したトークンは、秘密鍵が変わらない限り 期限まで有効だ。「今すぐログアウトさせたい」が難しいので、実務では有効期限を短くして リフレッシュトークンで更新する、失効リストを別に持つ、などで補う。

実例:

  • JWT: 多くの SPA / API 認証、OAuth2 のアクセストークン、Firebase Auth
  • セッション: 伝統的な Web アプリ(Rails、Django の既定)
  • OAuth2 / OpenID Connect: 「〜でログイン」全般、企業の SSO。OAuth と認可フローで作る

裏どり:

  • alg=none は実在した事故: 2015 年に複数の JWT ライブラリで、ヘッダの alg を鵜呑みにする実装が問題になった。公開鍵方式を共通鍵方式と偽って、公開鍵を鍵として署名を通す変種もある。検証側が受け入れるアルゴリズムを固定するのが対策になる
  • どこに置くかで攻撃面が変わる: ②で表にしたとおり、HttpOnly は盗まれることを、SameSite は勝手に使われることを塞ぐ。別の攻撃なので片方では埋まらない。それぞれの効き方は「エスケープした」では足りない送れることを使う攻撃で測った
  • 短命 + リフレッシュが定番: アクセストークンを数分から数十分にし、長命なリフレッシュトークンで更新する。失効できない代わりに、失効していない窓を短くするという考え方になる
  • 署名は HMAC: この章の実装は共通鍵の HMAC-SHA256。公開鍵署名(RS256)にすると、RSA の秘密鍵を持つ発行者だけがトークンを作れて、検証は公開鍵で誰でもできるようになる
  • セッションIDは推測できてはいけない: サーバが覚える方式の安全性は、IDが当てられないことに全部乗っている。だから連番ではなく、暗号論的に安全な乱数から作る

簡略化したこと

  • HS256(共通鍵)のみ: RS256(公開鍵署名)は RSA の章の署名で作れる。 公開鍵方式なら「検証は公開鍵で誰でも、発行は秘密鍵でサーバだけ」になる
  • クレームは sub/exp のみ: iss(発行者)/aud(対象)/nbf(有効開始)などは省略
  • セッション/OAuth2 は概念の説明のみ: 実装は JWT に絞った
  • リフレッシュトークン・失効リストなし

参考資料

  • jwt.io — JWT をその場でデコード・検証できる。中身が丸見えなのを確認できる
  • RFC 7519 — JWT の規格
  • OAuth 2.0 Simplified — OAuth2 の平易な解説