認証と認可
実装:
crypto/jwt// 実行:go test ./crypto/jwt/
ログインの裏側。まず「認証(あなたは誰)」と「認可(何をしてよい)」という似て非なる2つを区別する。次に、ログイン状態を覚える2つの方式、すなわちサーバが覚えるセッション方式と、サーバが覚えなくてよい JWT 方式を作る。JWT の「サーバが署名した通行証」がなぜ改竄できないのか、ここまでに作った鍵付きの指紋がそのまま効いてくる。
この章で作るもの
「ログインしている状態」をどう保つか。2つの方式を作って比べる。サーバが覚えるか、署名で証明させるかの二択になる。
順に見ていく。
- 認証と認可は別物: 誰かを確かめることと、何をしてよいかを決めることは順番が違う
- サーバが覚える(セッション + Cookie): 対応表を持つ。Cookie に何の印を付けるかで攻撃面が変わる
- サーバが覚えない(JWT): 署名した通行証を渡す。守っているのは秘密ではなく改竄されないこと
- 検証側がアルゴリズムを決める: 相手が渡してきた値で、相手の検証方法を決めてはいけない
- パスワードを預からない(OAuth2 の入口): 3役に分けて、自分はパスワードを見ない
① 認証と認可は別物
- 認証(Authentication): 「あなたは誰か」を確かめる。ログイン(ID/パスワード)がこれ
- 認可(Authorization): 「あなたは何をしてよいか」を決める。管理者だけ削除できる、など
順番は認証 → 認可。まず誰かを確かめ、その人の権限を見る。この2つを混同すると 「ログインさえすれば何でもできる」ような穴が生まれる。JWT は認証の結果(誰か)を 運ぶ通行証で、その中に認可の情報(権限)を載せることもある。
② サーバが覚える(セッション + Cookie)
ログインに成功すると、サーバはランダムなセッションIDを作り、 「このID = user-42」という対応をサーバ側に保存する。そのIDを Cookie でブラウザに渡し、 以降のリクエストにはブラウザが自動で Cookie を付ける。サーバは Cookie のIDから 対応表を引いて「誰か」を知る。
素直だが弱点がある。サーバが状態を持つことだ。サーバが複数台なら対応表を共有 (Redis 等)しないといけないし、リクエストのたびに対応表を引く。
Cookie に何の印を付けるか
Cookie は自動で付いて飛ぶ。便利さの正体がそのまま危うさの正体でもあるので、何を付けるかで守る相手が変わる。
| 印 | 何をするか | 何から守るか |
|---|---|---|
HttpOnly | JavaScript から読めなくする | ページに入り込んだスクリプトに盗まれること |
Secure | HTTPS のときだけ送る | 経路で盗み見られること |
SameSite | 別サイトから来た要求では付けない | 別サイトから勝手に送られる要求 |
Domain / Path | どの範囲へ送るか決める | 送る必要のない相手に届くこと |
上2つと下2つで役割が違う。HttpOnly と Secure は盗まれないため、SameSite は勝手に使われないための印になる。盗まれることと勝手に使われることは別の攻撃なので、片方の印だけでは片方しか塞がらない。
そして置き場所そのものが選択になる。ログイン状態を JavaScript から読める場所(localStorage など)に置くと、ページに入り込んだスクリプトに読まれる。HttpOnly の Cookie に置くと読まれなくなるが、今度は自動で付いて飛ぶので、別サイトからの要求に乗る。どちらにも穴があり、置き場所を選ぶことは攻撃の種類を選ぶことになる。詳しくは送れるのに読めないと送れることを使う攻撃で測った。
③ サーバが覚えない(JWT)
JWT の発想は逆転している。サーバは「この人は user-42 です」と紙に書いて署名し、 その紙(トークン)をクライアントに渡す。以降サーバは何も覚えない。トークンを見せられたら、 署名を検証するだけで「確かに自分が発行した、改竄されていない」と分かる。 対応表が要らない(ステートレス)ので、サーバが何台でもスケールする。
JWT の中身: header.payload.signature
JWT は3つのパートをドットで繋いだ文字列。
// 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 にパスワードのような 秘密は入れてはいけない。
// 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 から 署名を計算し直し、トークンの署名と一致するかを見る。
// 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 を書き換えると、サーバが計算し直した署名がトークンの署名と食い違い、改竄が検出される。秘密鍵を当てられない限り、攻撃者は正しい署名を作れない。
サーバが発行する正規のトークン
header アルゴリズム payload sub=user-42(誰でも読める) signature 秘密鍵で計算(サーバだけ作れる)
攻撃: ペイロードを書き換えて権限を奪おうとする
サーバが検証: 受け取ったヘッダ+ペイロードから署名を計算し直す
mKrGFgトークンの署名 R9oVsQ✗ 不一致 → 改竄を検出、リクエスト拒否
④ 検証側がアルゴリズムを決める
JWT には有名な攻撃がある。ヘッダの alg を none に書き換えて「署名なし」を 主張する攻撃だ。実装がヘッダの alg を鵜呑みにして「none なら検証しない」とすると、 誰でも好きな payload のトークンを作れてしまう。Verify はトークンの alg を読まず、 常に HS256 で署名を計算するので、この攻撃は署名不一致で弾かれる。 **「検証側がアルゴリズムを固定する」**のが鉄則。
⑤ パスワードを預からない(OAuth2 の入口)
「Google でログイン」のような、自分のサーバがパスワードを預からずに認証する 仕組みが OAuth2 になる。ここでは入口だけを示し、流れの中身は次章の OAuth と認可フローで作る。登場人物は3役:
- クライアント(あなたのアプリ)
- 認可サーバ(Google。ユーザーを認証してトークンを発行する)
- リソースサーバ(Google のAPI。トークンを見てデータを渡す)
ユーザーは Google にだけパスワードを渡し、あなたのアプリは Google が発行した トークン(中身はしばしば JWT)を受け取る。あなたのアプリはパスワードを一切見ない。 この3役の間でトークンをやり取りする流れが OAuth2 で、その通行証の信頼性を 支えているのが、この章で作った署名。
設計の観点
- 覚えるか、証明させるか: ログイン状態の保ち方は、サーバが対応表を持つか、持たずに署名で確かめるかの二択になる。状態をどちらが持つかが、そのままスケールと失効のしやすさを裏返しに決める
- 守っているのは秘密ではなく、改竄されないこと: JWT の中身は誰でも読める。混同すると、読まれて困るものを入れてしまう
- 検証側がアルゴリズムを決める: 受け取ったトークンの自己申告を信じると、
algをnoneに書き換えられて終わる。相手が渡してきた値で、相手を検証する方法を決めてはいけない - 発行と検証を分けられるか: 共通鍵だと検証できる者は発行もできる。公開鍵署名にすると「検証は誰でも、発行はサーバだけ」に分かれ、検証を外へ配れるようになる
- 失効は後から足せない: 「サーバが覚えない」と決めた時点で、発行済みを取り消す手段を失う。有効期限を短くする、失効リストを別に持つ、といった対処はすべてこの決定の埋め合わせになる
- 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 の平易な解説