認証と認可の実装
この部の 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 は速いことが目的の関数です。 攻撃者は流出したハッシュに対し、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. サーバーはストアを引いてユーザーを特定する
まずこれを検討してください。 単純で、失効させられます。
Cookie の属性は必ず設定する
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,
})| 属性 | 付け忘れると |
|---|---|
HttpOnly | XSS でセッション ID を盗まれる |
Secure | 平文通信で盗聴される |
SameSite | CSRF(セキュリティ)が成立しやすくなる |
この3つは必ず設定します。 レビューで真っ先に見るポイントです。
ペイロード 実際に運びたいデータ本体(ヘッダなどの付随情報を除いた部分)
クレーム JWT の中に入っている個々の項目(sub / exp / iss など)
「ペイロードが大きい」= 送っているデータ本体が大きい、という意味です。 HTTP・gRPC・キューなど、あらゆる通信の文脈で使われます。
トークン方式(JWT)
JWT は、署名付きの JSON です。ピリオドで区切られた3つの部分からできています。
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3NTQ2MDAwMDB9.dBjftJeZ4CV...
└──── header ────┘ └────────── payload ──────────┘ └── signature ──┘
署名アルゴリズム 中身(ユーザーID、有効期限) 改ざん検知用の署名
header と payload は、Base64 でエンコードされているだけです。 誰でもデコードして中身を読めます。
echo 'eyJzdWIiOiIxMjMifQ' | base64 -d # {"sub":"123"}署名が保証するのは「改ざんされていないこと」だけで、 「見られないこと」ではありません。
個人情報や秘密をペイロードに入れてはいけません。
JWT の検証は署名を確かめるだけなので、サーバーは 「このトークンはもう無効」と知る手段を持ちません。
- ログアウトしても、そのトークンは有効期限まで使える
- アカウントを停止しても、既存トークンは通り続ける
- 権限を剥奪しても、古い権限のまま動く
対策として失効リストを持つと、結局ストアが必要になり、 JWT の利点(ストア不要)が消えます。
だから実務では、次の構成が一般的です。
- アクセストークン: JWT、有効期限は短く(5〜15分)
- リフレッシュトークン: サーバー側で管理し、失効可能にする
ライブラリを使っていても、設定を誤ると素通りします。
- アルゴリズムを固定する —
alg: noneや、 RS256 を HS256 として検証させる古典的な攻撃がある exp(有効期限)を検証するiss(発行者)とaud(対象)を検証する — 他サービス用のトークンを弾く- 署名鍵を安全に管理する — 環境変数から読む(セキュリティ)
トークンをどこに保存するか
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を知らない攻撃者はトークンに交換できない
stateパラメータを検証しない — CSRF が成立する。 リクエスト時に発行したランダム値が、コールバックで一致するか必ず確認する- リダイレクト URI を完全一致で検証しない — 前方一致にすると、攻撃者のページにトークンを送れてしまう
- Implicit フローを使う — トークンが URL に露出する。現在は非推奨
- クライアントシークレットをフロントエンドに置く — ブラウザや モバイルアプリでは秘密を守れない。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
}他人のリソースに対して 403 Forbidden を返すと、
「その ID は存在する」という情報が漏れます。
ID を順に試せば、全体の件数や他人のデータの存在が推測できます。 見せてはいけないものは、存在ごと隠して 404 を返すのが定石です。
- 画面でボタンを隠すのは、認可ではありません。 API を直接叩けば通ります
- 一部のエンドポイントだけチェックすると、必ず漏れが出ます
- 新しいエンドポイントを足すたびに、人間が忘れずに書くのは無理です
既定で拒否し、明示的に許可する構造にしてください。 ミドルウェアで全ルートに認可チェックを通し、 公開エンドポイントだけを明示的に除外するのが安全です。
サービス間の認証
マイクロサービスの実務のマイクロサービスでは、サービス同士も認証します。
| 方式 | 内容 |
|---|---|
| mTLS | 双方が証明書を提示する。サービスメッシュが自動化してくれる |
| サービスアカウント | GCP なら Workload Identity で、Pod に身元を与える |
| API キー | 単純だが、ローテーションと管理が課題になる |
API ゲートウェイが受け取ったユーザーのトークンを、 そのまま下流サービスへ横流しするのは危険です。
下流サービスが侵害されると、そのトークンで ユーザーとして何でもできてしまいます。
境界でトークンを検証し、内部では 必要な情報だけを持つ内部用トークンに変換するのが定石です。
ログインユーザーが `/api/orders/{id}` で自分の注文を取得できる API を実装しました。レビューで最初に確認すべきことは?
実務の落とし穴まとめ
- パスワードを SHA-256 で保存 — bcrypt / argon2 を使う
- ログイン失敗のメッセージを分ける — アカウントの存在が漏れる
- Cookie に
HttpOnly/Secure/SameSiteが無い — 盗まれる - JWT に個人情報を入れる — 暗号化されていない。誰でも読める
- JWT で即時ログアウトを実装しようとする — 原理的にできない
algを固定せず検証 — 署名検証が素通りする- localStorage にトークン — XSS で盗まれる
- OAuth の
stateを検証しない — CSRF が成立する - リダイレクト URI を前方一致で検証 — 攻撃者に送れてしまう
- アクセストークンで本人確認 — 認証には ID トークンを使う
- 所有者チェックの漏れ(IDOR) — 最も多い実害
- 画面でボタンを隠して認可したつもり — API は直接叩ける
まとめ
- 暗号・プロトコルは自作しない。仕組みを学ぶのは穴に気づくため
- パスワードは bcrypt / argon2。そもそも持たない選択肢を先に検討する
- ログイン状態は、まずセッション方式を検討する。
Cookie には
HttpOnlySecureSameSiteを必ず付ける - JWT は暗号化されておらず、失効もできない。 短命のアクセストークン + 失効可能なリフレッシュトークンで運用する
- OAuth 2.0 は認可、OIDC が認証。認可コードフロー + PKCE を使う
- 認可のほうが事故る。所有者チェック(IDOR)を必ず入れ、 既定で拒否し、サーバー側で、全エンドポイントに適用する
- 存在を隠したいものは 404 を返す
公式ドキュメント
迷ったら一次情報に戻ってください。
| 対象 | リンク |
|---|---|
| OWASP 認証チートシート | https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html |
| RFC 6749(OAuth 2.0) | https://www.rfc-editor.org/rfc/rfc6749 |
| OpenID Connect Core | https://openid.net/specs/openid-connect-core-1_0.html |
| MDN: Cookie | https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies |
| IPA 安全なウェブサイトの作り方 | https://www.ipa.go.jp/security/vuln/websecurity/ |
章末問題
「ログアウトしたら、そのトークンをすぐ無効にしたい」という要件があります。JWT だけで実現できますか。
社内管理画面で、一般社員には表示されないボタンを、管理者だけに表示するようにしました。これで認可は十分ですか。
次の章では、ここまでの防御が構造的に効かない領域を扱います。 LLM には、命令とデータを分離する手段がありません。