Tech Learning Daily

2026-08-08 (Sat) — 第 23 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Security 🌿 基礎 ⏱ 約 8 分

「Google でログイン」を押した瞬間、裏で何がやり取りされているのか?

「Google でログイン」ボタンを設置したことがあるはずだ。client_idclient_secret を環境変数に入れ、コールバック URL を Google Console に登録すれば動く。だが画面がなぜ 2 回もリダイレクトするのか、なぜ一度で済ませずに「コード」をもらってから改めて「トークン」と交換するのか、説明できるだろうか。

🎯 3 行まとめ
  • OAuth 2.0 の認可コードフローは、ブラウザの URL に乗って漏れやすい「認可コード」と、サーバー間の TLS 通信でしかやり取りしない「アクセストークン」を分離しており、コードだけ盗まれても交換できない設計になっている
  • state パラメータは CSRF 対策、redirect_uri の完全一致検証は横取り対策で、どちらも「認可コードが正しい相手にだけ返ってくる」ことを別々の角度から守っている
  • スマホアプリや SPA のように client_secret を安全に持てないクライアントは PKCE を使い、OAuth はそもそも「認可」しか扱わないため「認証」が要る場面は OpenID Connect の ID Token が補う

登場人物とリダイレクトの全体像

OAuth 2.0(RFC 6749)には 4 つの役割がある。ログインしようとしている本人であるリソースオーナー、あなたが作った Web アプリであるクライアント、Google や GitHub 側の認可サーバー、そしてユーザーのデータを実際に持つリソースサーバーだ。認可コードフローでは、クライアントはまずユーザーのブラウザを認可サーバーの /authorize エンドポイントへresponse_type=codeclient_idredirect_uriscopestate 付きでリダイレクトする。ユーザーが認可サーバー上でログインし同意すると、認可サーバーはあらかじめ登録された redirect_uri認可コード(短命で一度しか使えないランダムな文字列)を付けてブラウザを送り返す。

┌────────┐ ①/authorize ┌──────────┐
│ Browser │────────────▶│認可サーバー│
└────────┘             └──────────┘
     │②code(URLに乗る)      │
     ▼                      │
┌────────┐                  │
│ App     │③code→token(back)│
│(server) │◀─────────────────┘
└────────┘
     │④access token/id_token
     ▼
┌──────────────┐
│ リソースサーバー │
└──────────────┘

ここまでで手元にあるのはまだ「コード」だけであり、API を叩けるアクセストークンではない。次の一手が、このフローの設計で最も重要な部分になる。

なぜコードとトークンを分けるのか — フロントチャネルとバックチャネル

一見、認可コードをもらった時点でそのままログイン完了にしてしまえば早い。しかし認可コードはブラウザのリダイレクト(フロントチャネル)を経由して渡ってくる。URL はブラウザ履歴・プロキシのアクセスログ・Referer ヘッダーなど、TLS で暗号化された通信路の外側にも残りうる経路だ。ここに長生きするアクセストークンそのものを乗せてしまうと、漏洩したログ 1 行でアカウントを乗っ取られかねない。

そこで OAuth はもう一段階を挟む。クライアントのサーバーが受け取った認可コードを、今度はブラウザを介さずサーバー間の TLS 通信(バックチャネル)で認可サーバーの /token エンドポイントへ直接送り、client_secret(またはこの後説明する PKCE の証明)とセットで初めてアクセストークンと交換する。コードが URL に一瞬乗って見えたとしても、それを再利用するには「秘密」を追加で提示できないと成立しない。これが冒頭の問いへの答えだ — コードとトークンを分けているのは、経路の安全性が異なる 2 つの区間をまたぐからである。

この設計をさらに補強するのが state パラメータと redirect_uri の検証だ。state はクライアントが発行時に生成したランダム値で、コールバック時に同じ値が返ってくることを確認する。これがないと、攻撃者が自分のセッションの認可コードを被害者のブラウザに踏ませて「攻撃者のアカウントでログインした状態」を強制する CSRF が成立する。一方 redirect_uri は、認可サーバーが「事前に登録された値と完全一致するか」を照合する。一致確認がなければ、認可コードを攻撃者の管理するコールバック URL に横流しさせることができてしまう。stateredirect_uri 検証は守っている対象が別物であり、片方だけでは不十分だ。

🍱 たとえるなら

クロークで手荷物を預けるときを思い浮かべるとよい。荷物を預けるとその場では「引換券」だけを渡される。引換券自体は他人に見られてもさほど困らない――それだけでは荷物を受け取れないからだ。実際に荷物を取り出すときは、引換券に加えて「預けたときの控え(合言葉)」を裏口の係員に示す必要がある。認可コードが引換券、client_secret や PKCE の証明が控えにあたる。引換券だけを盗み見た人がいても、控えを持っていなければ荷物(アクセストークン)は渡されない。

秘密を持てないクライアントのための PKCE

SPA やモバイルアプリはコードがユーザーの端末上で動くため、client_secret をアプリに埋め込んでも解析されればすぐ漏れる。「秘密を安全に保管できないクライアント」向けに RFC 7636 が定義したのが PKCE(Proof Key for Code Exchange)だ。仕組みは client_secret の代わりに、リクエストごとに使い捨てる証明を作る。まず認可リクエストを送る前にランダムな code_verifier(43〜128 文字)をアプリ内だけで生成し、それを SHA-256 でハッシュして base64url エンコードした code_challenge/authorize に送る。認可サーバーはこの challenge を認可コードと結びつけて記憶する。

# code_verifier を生成(実際の SDK もこれと同じ処理をしている)
VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\n')

# S256 で code_challenge を導出
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary \
  | openssl base64 | tr -d '=\n' | tr '+/' '-_')

トークン交換のリクエストでは、challenge ではなく元の code_verifier を送る。認可サーバーは受け取った verifier を同じ手順でハッシュし、記憶しておいた challenge と一致するかを照合する。認可コードを横取りした攻撃者は challenge しか見ておらず、SHA-256 は一方向関数なので challenge から verifier を逆算できない。code_challenge_method には plain(verifier をそのまま challenge にする)も規格上は存在するが、これは challenge を見れば verifier がそのままわかってしまうため、制約の強い環境向けの後方互換にすぎず、通常は S256 を使う。

OAuth は認可、認証を足すのは OpenID Connect

ここまでの仕組みが守っているのは「このクライアントがこのユーザーの代わりに API を叩いてよいか」という認可(authorization)であり、「このユーザーは本当に本人か」という認証(authentication)ではない。RFC 6749 自体には身元情報を返す標準的な方法が定義されていない。ここに OpenID Connect(OIDC)が OAuth の上に ID Token という JWT を追加し、認証の役割を担わせる。

ID Token には iss(発行者)・sub(ユーザーを一意に指す識別子)・aud(想定するクライアントの client_id)・exp(有効期限)・iat(発行時刻)が必須クレームとして入っており、クライアントアプリはこれを検証して「誰がいつログインしたか」を確認する。一方のアクセストークンは API(リソースサーバー)側が「何をしてよいか」を判断するためのもので、JWT 形式のこともあれば意味を持たない不透明な文字列(opaque token)のこともある。同じレスポンスに両方入っていても、ID Token を読むのはクライアント、アクセストークンを読むのは API という受け手が違う。

💼 実務でどう出会うか

Auth0 や Okta、Firebase Auth のような IdP を組み込むとき、コンソールに登録する Authorized redirect URI はここまでの redirect_uri 完全一致検証そのものだ。ステージング環境の URL を一つ登録し忘れて「Google ログインだけ本番で 400 になる」という事象に遭遇したら、まずここを疑うとよい。また GitHub App や Slack App のような外部連携を実装する際、要求する scope は最小限にとどめる方が安全であり、後から広げる方が事故は少ない。SPA でバックエンドを持たない構成なら client_secret を置けないので PKCE 対応の SDK(@auth0/auth0-spa-js 等)を使うのが定石になる。

⌨️ 手を動かす(5 分)

実際の OAuth クライアント SDK が PKCE で行っている「verifier の生成」と「S256 での challenge 導出」を、openssl だけで再現して確かめる。

# code_verifier を生成(43〜128文字のランダム文字列という規格に沿う)
VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\n')
echo "code_verifier ($(echo -n "$VERIFIER" | wc -c) 文字): $VERIFIER"

# SHA-256 でハッシュして base64url エンコード = code_challenge
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary \
  | openssl base64 | tr -d '=\n' | tr '+/' '-_')
echo "code_challenge (S256): $CHALLENGE"

code_verifier はちょうど 43 文字になり(32 バイトを base64url した長さ)、RFC 7636 が定める最小長と一致する。code_challenge は verifier と見た目がまったく異なる文字列になっているはずだ。実際の認可リクエストではこの challenge だけが URL に乗って外に出て、verifier は最後までクライアントの中に留まる――だからこそ challenge を盗み見ても何の役にも立たない。

🙅 よくある誤解
  • client_secret さえ守っていれば OAuth は安全 — client_secret はトークン交換の認証にしか関与しない。state がなければ CSRF、redirect_uri の完全一致検証がなければコードの横流しが別経路で成立してしまう。3 つがそろって初めて安全なフローになる。
  • 「OAuth でログインしている」 — OAuth 2.0 自体はユーザーの身元を証明する仕組みを持たない、あくまで認可のプロトコルだ。「ログインした」と判断できるのは OpenID Connect が追加した ID Token を検証した結果であり、両者は別の規格の話をしている。
  • JWT 形式のトークンなら中身を見れば信頼してよい — JWT はただの署名付きデータ構造であり、署名を発行者の公開鍵(JWKS)で検証して初めて改ざんされていないと言える。検証を省いてペイロードをそのまま信用するのは、封を確認せず中身だけ読むのと同じだ。
📖 用語ミニ辞典
認可コード
認可サーバーがユーザーの同意後に発行する、短命で一度しか使えないランダムな文字列。これ単体ではアクセストークンと交換できるだけで API は叩けない。
state パラメータ
クライアントが認可リクエスト時に生成するランダム値。コールバックで同じ値が返るかを確認し、CSRF を防ぐ。
PKCE
client_secret を安全に保持できないクライアント向けに、使い捨ての code_verifier / code_challenge のペアで認可コードの横取りを防ぐ仕組み。
ID Token
OpenID Connect が OAuth に追加する JWT。iss・sub・aud・exp・iat などのクレームでユーザーの認証結果を表す。
アクセストークン
リソースサーバー(API)が「何を許可されているか」を判断するためのトークン。JWT の場合も opaque な文字列の場合もある。
🔗 もっと深く