プログラマのための IT 教科書

認証と認可の実装

この部の 3 / 8 章 ・ 全体で 64 / 76 章 ・ 読了目安 50 分

この章を読むとできるようになること
  • セッション方式とトークン方式を要件で選べる
  • JWT で失効できない理由を説明できる
  • 所有者チェックの漏れをレビューで見つけられる

セキュリティで、認証と認可の違いを扱いました。

認証(Authentication)  あなたが誰か     → ログイン
認可(Authorization)   何をしてよいか   → 権限チェック

この章では、それを実際にどう実装するかを扱います。 自分でゼロから作ることは(後述するように)ほとんどありませんが、 仕組みを知らないまま使うと、必ず穴を空けます。

大前提: 自分で作らない

暗号やプロトコルの実装を自作するのは、ほぼ確実に間違いです。

やらない使うもの
パスワードのハッシュ化を自作するbcrypt / argon2 / scrypt のライブラリ
JWT の署名検証を自作する実績のあるライブラリ
OAuth のフローを自作するIdP(Auth0 / Firebase Auth / Cognito / Google)
セッション管理を自作するフレームワーク標準 / Redis + 既製ミドルウェア

それでも仕組みを学ぶのは、「使い方を間違えないため」です。 この章の目的は、実装できるようになることではなく、 レビューで穴に気づけるようになることです。

パスワードの扱い

保存

平文で保存               論外
MD5 / SHA-1 / SHA-256    不十分(GPU で秒間数十億回試せる)
bcrypt / argon2 / scrypt  正解(意図的に遅く、計算資源を要求する)
// bcrypt: ソルトは自動で生成され、ハッシュ文字列に含まれる
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
 
// 照合
err = bcrypt.CompareHashAndPassword(storedHash, []byte(inputPassword))
なぜ SHA-256 では駄目なのか

SHA-256 は速いことが目的の関数です。 攻撃者は流出したハッシュに対し、GPU で1秒あたり数十億通りを試せます。

bcrypt や argon2 は意図的に遅く(数十〜数百ミリ秒)作られており、 総当たりの速度を実用的でない水準まで落とします。 argon2 はさらにメモリも大量に要求するので、GPU での並列化も効きにくくなります。

「ハッシュ化してあるから安全」ではありません。何でハッシュ化したかが問題です。

ソルトとペッパー

ソルトは、ユーザーごとに違うランダム値です。 これが無いと、同じパスワードの人が同じハッシュになり、 事前計算した表(レインボーテーブル)で一気に破られます。

bcrypt / argon2 はソルトを自動で生成し、結果の文字列に含めます。 自分でソルトを管理する必要はありません。

照合時の注意

// 悪い: ユーザーの存在有無が漏れる
user, err := store.FindByEmail(email)
if err != nil { return errors.New("そのメールアドレスは登録されていません") }
if !checkPassword(user, password) { return errors.New("パスワードが違います") }
 
// 良い: どちらでも同じメッセージ・同程度の処理時間にする
if err != nil || !checkPassword(user, password) {
    return errors.New("メールアドレスまたはパスワードが正しくありません")
}

エラーメッセージを分けると、そのメールアドレスが登録済みかを外部から判定できます (アカウント列挙)。パスワードリセット画面でも同じ配慮が必要です。

実務ではパスワードを持たないのが最善

パスワードを保存するということは、流出のリスクを引き受けるということです。

Google / GitHub などでのソーシャルログインや、 社内システムなら SSO を使えば、自社でパスワードを持たずに済みます。

新規サービスで自前のパスワード認証を作る前に、 「本当に必要か」を検討してください。

ログイン状態をどう保つか

Web と HTTPで見たとおり、HTTP はステートレスです。 ログイン後の各リクエストで「自分が誰か」を示す必要があります。

方式は大きく2つです。

セッション方式トークン方式(JWT など)
何を渡すか意味のない ID中身を持った署名付きデータ
実体の置き場所サーバー(DB / Redis)無し(トークン自体が情報)
検証ストアに問い合わせ署名を検証するだけ
即時失効できる(ストアから消す)できない(有効期限まで有効)
スケールストアが必要ストア不要

セッション方式

1. ログイン成功 → ランダムなセッション ID を発行
2. サーバー側に「このID = ユーザー123」を保存(Redis など)
3. Cookie でクライアントに渡す
4. 以降のリクエストで Cookie が自動的に送られる
5. サーバーはストアを引いてユーザーを特定する

まずこれを検討してください。 単純で、失効させられます。

http.SetCookie(w, &http.Cookie{
    Name:     "session",
    Value:    sessionID,
    HttpOnly: true,                    // JavaScript から読めない(XSS で盗まれない)
    Secure:   true,                    // HTTPS でのみ送信される
    SameSite: http.SameSiteLaxMode,    // 他サイトからの遷移では送らない(CSRF 対策)
    Path:     "/",
    MaxAge:   60 * 60 * 24 * 7,
})
属性付け忘れると
HttpOnlyXSS でセッション ID を盗まれる
Secure平文通信で盗聴される
SameSiteCSRF(セキュリティ)が成立しやすくなる

この3つは必ず設定します。 レビューで真っ先に見るポイントです。

用語: ペイロード / クレーム
ペイロード  実際に運びたいデータ本体(ヘッダなどの付随情報を除いた部分)
クレーム    JWT の中に入っている個々の項目(sub / exp / iss など)

「ペイロードが大きい」= 送っているデータ本体が大きい、という意味です。 HTTP・gRPC・キューなど、あらゆる通信の文脈で使われます。

トークン方式(JWT)

JWT は、署名付きの JSON です。ピリオドで区切られた3つの部分からできています。

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3NTQ2MDAwMDB9.dBjftJeZ4CV...
└──── header ────┘ └────────── payload ──────────┘ └── signature ──┘
   署名アルゴリズム      中身(ユーザーID、有効期限)      改ざん検知用の署名
JWT は暗号化されていない

header と payload は、Base64 でエンコードされているだけです。 誰でもデコードして中身を読めます。

echo 'eyJzdWIiOiIxMjMifQ' | base64 -d    # {"sub":"123"}

署名が保証するのは「改ざんされていないこと」だけで、 「見られないこと」ではありません。

個人情報や秘密をペイロードに入れてはいけません。

JWT は失効できない

JWT の検証は署名を確かめるだけなので、サーバーは 「このトークンはもう無効」と知る手段を持ちません。

  • ログアウトしても、そのトークンは有効期限まで使える
  • アカウントを停止しても、既存トークンは通り続ける
  • 権限を剥奪しても、古い権限のまま動く

対策として失効リストを持つと、結局ストアが必要になり、 JWT の利点(ストア不要)が消えます。

だから実務では、次の構成が一般的です。

  • アクセストークン: JWT、有効期限は短く(5〜15分)
  • リフレッシュトークン: サーバー側で管理し、失効可能にする
JWT の検証で必ず確認すること

ライブラリを使っていても、設定を誤ると素通りします。

  1. アルゴリズムを固定する — alg: none や、 RS256 を HS256 として検証させる古典的な攻撃がある
  2. exp(有効期限)を検証する
  3. iss(発行者)と aud(対象)を検証する — 他サービス用のトークンを弾く
  4. 署名鍵を安全に管理する — 環境変数から読む(セキュリティ)

トークンをどこに保存するか

localStorage         → XSS があれば JavaScript で読み取られる
HttpOnly Cookie      → JavaScript から読めない。CSRF 対策とセットで使う
メモリ(変数)        → タブを閉じると消えるが、最も漏れにくい

「localStorage に JWT」は広く見かけますが、XSS 一発で終わります。 可能なら HttpOnly Cookie を選んでください。

OAuth 2.0 — パスワードを渡さずに権限を委譲する

「このアプリに Google カレンダーを読ませたい」時、 Google のパスワードをそのアプリに渡すわけにはいきません。

OAuth 2.0 は、この問題を解決する仕組みです。

登場人物
  リソースオーナー   あなた(ユーザー)
  クライアント       権限が欲しいアプリ
  認可サーバー       Google(同意を取り、トークンを発行する)
  リソースサーバー   Google カレンダー API

認可コードフロー(+ PKCE)

現在の標準的なフローです。

1. アプリが、ブラウザを認可サーバーへ送る
   (+ code_challenge = ランダム値のハッシュ を添える)
2. ユーザーが Google でログインし、「カレンダーの閲覧を許可」に同意
3. 認可サーバーが、アプリのリダイレクト URI に「認可コード」を返す
4. アプリがサーバー側で、認可コード + code_verifier(元のランダム値)を送る
5. 認可サーバーがアクセストークンを返す
6. アプリがそのトークンでカレンダー API を呼ぶ

要点は2つです。

  • ステップ3の認可コードは、それだけでは使えない(4 で交換が必要)
  • PKCE により、認可コードを横取りされても code_verifier を知らない攻撃者はトークンに交換できない
よくある実装ミス
  1. state パラメータを検証しない — CSRF が成立する。 リクエスト時に発行したランダム値が、コールバックで一致するか必ず確認する
  2. リダイレクト URI を完全一致で検証しない — 前方一致にすると、攻撃者のページにトークンを送れてしまう
  3. Implicit フローを使う — トークンが URL に露出する。現在は非推奨
  4. クライアントシークレットをフロントエンドに置く — ブラウザや モバイルアプリでは秘密を守れない。PKCE を使う

OIDC — OAuth は認証ではない

OAuth 2.0 は「認可」の仕組みで、「この人が誰か」は保証しません。

「Google でログイン」を実現するには、その上に載る OpenID Connect(OIDC) を使います。

OAuth 2.0      → アクセストークン(何ができるか)
OIDC を足す    → ID トークン(誰であるか。JWT 形式)

ID トークンには sub(そのユーザーの一意な ID)、 email、iss、aud、exp が入っています。

アクセストークンを認証に使ってはいけません。 「トークンが有効だから本人」という判断は、 別のアプリ向けに発行されたトークンを持ち込まれると破綻します。 aud を検証できる ID トークンを使ってください。

認可 — 実装ミスが最も多い場所

認証より、認可のほうがはるかに事故ります。 認証は「通るか通らないか」なので気づきますが、 認可の漏れは正常に動いているように見えます。

モデル

方式考え方例
RBAC役割に権限を紐づけるadmin / editor / viewer
ABAC属性で判定する「所属部署が同じなら閲覧可」
リソースベースそのリソースの所有者かで判定「自分の注文だけ見られる」

実務では RBAC + リソースベースの組み合わせが最も多いです。

最頻出の脆弱性: IDOR

// 危険: ログインしていれば、他人の注文も見られる
func GetOrder(ctx context.Context, id string) (*Order, error) {
    return store.FindOrder(ctx, id)
}

ログインはしているので認証は通っています。 しかし「その注文が自分のものか」を確認していません。

URL の /orders/1001 を /orders/1002 に書き換えるだけで、 他人の注文が見えます。これを IDOR(安全でない直接オブジェクト参照)と呼びます。

// 正しい: 所有者を条件に含める
func GetOrder(ctx context.Context, userID, orderID string) (*Order, error) {
    o, err := store.FindOrder(ctx, orderID)
    if err != nil { return nil, err }
    if o.UserID != userID {
        return nil, ErrNotFound   // ← ErrForbidden ではなく NotFound を返す
    }
    return o, nil
}
存在を隠す時は 404 を返す

他人のリソースに対して 403 Forbidden を返すと、 「その ID は存在する」という情報が漏れます。

ID を順に試せば、全体の件数や他人のデータの存在が推測できます。 見せてはいけないものは、存在ごと隠して 404 を返すのが定石です。

認可はサーバー側で、すべての入口に
  • 画面でボタンを隠すのは、認可ではありません。 API を直接叩けば通ります
  • 一部のエンドポイントだけチェックすると、必ず漏れが出ます
  • 新しいエンドポイントを足すたびに、人間が忘れずに書くのは無理です

既定で拒否し、明示的に許可する構造にしてください。 ミドルウェアで全ルートに認可チェックを通し、 公開エンドポイントだけを明示的に除外するのが安全です。

サービス間の認証

マイクロサービスの実務のマイクロサービスでは、サービス同士も認証します。

方式内容
mTLS双方が証明書を提示する。サービスメッシュが自動化してくれる
サービスアカウントGCP なら Workload Identity で、Pod に身元を与える
API キー単純だが、ローテーションと管理が課題になる
ユーザーのトークンをそのまま転送しない

API ゲートウェイが受け取ったユーザーのトークンを、 そのまま下流サービスへ横流しするのは危険です。

下流サービスが侵害されると、そのトークンで ユーザーとして何でもできてしまいます。

境界でトークンを検証し、内部では 必要な情報だけを持つ内部用トークンに変換するのが定石です。

ログインユーザーが `/api/orders/{id}` で自分の注文を取得できる API を実装しました。レビューで最初に確認すべきことは?

実務の落とし穴まとめ

  1. パスワードを SHA-256 で保存 — bcrypt / argon2 を使う
  2. ログイン失敗のメッセージを分ける — アカウントの存在が漏れる
  3. Cookie に HttpOnly / Secure / SameSite が無い — 盗まれる
  4. JWT に個人情報を入れる — 暗号化されていない。誰でも読める
  5. JWT で即時ログアウトを実装しようとする — 原理的にできない
  6. alg を固定せず検証 — 署名検証が素通りする
  7. localStorage にトークン — XSS で盗まれる
  8. OAuth の state を検証しない — CSRF が成立する
  9. リダイレクト URI を前方一致で検証 — 攻撃者に送れてしまう
  10. アクセストークンで本人確認 — 認証には ID トークンを使う
  11. 所有者チェックの漏れ(IDOR) — 最も多い実害
  12. 画面でボタンを隠して認可したつもり — API は直接叩ける

まとめ

  • 暗号・プロトコルは自作しない。仕組みを学ぶのは穴に気づくため
  • パスワードは bcrypt / argon2。そもそも持たない選択肢を先に検討する
  • ログイン状態は、まずセッション方式を検討する。 Cookie には HttpOnly Secure SameSite を必ず付ける
  • JWT は暗号化されておらず、失効もできない。 短命のアクセストークン + 失効可能なリフレッシュトークンで運用する
  • OAuth 2.0 は認可、OIDC が認証。認可コードフロー + PKCE を使う
  • 認可のほうが事故る。所有者チェック(IDOR)を必ず入れ、 既定で拒否し、サーバー側で、全エンドポイントに適用する
  • 存在を隠したいものは 404 を返す

公式ドキュメント

迷ったら一次情報に戻ってください。

章末問題

「ログアウトしたら、そのトークンをすぐ無効にしたい」という要件があります。JWT だけで実現できますか。

社内管理画面で、一般社員には表示されないボタンを、管理者だけに表示するようにしました。これで認可は十分ですか。

次の章では、ここまでの防御が構造的に効かない領域を扱います。 LLM には、命令とデータを分離する手段がありません。

読み終わったら記録しておくと、目次で進み具合が分かります。