OAuth は「認可」であって「認証」ではない
「OAuth でログイン」という言い方をよく聞きます。ですが OAuth 2.0(RFC 6749)は 認可(Authorization)のための仕組みであって、 認証(Authentication)の仕組みではありません。 認可の道具をログインに流用すると、なりすましの穴が空くことがあります。 ログインを正しくやりたいなら、OAuth 2.0 の上に立つ OpenID Connect(OIDC)を使います。
まずは下のデモで、認可コードフローが何を受け渡ししているか、 そして「アクセストークンでログイン」がどうやってなりすましを通してしまうかを操作して確かめてください。
① 認可コードフローを 1 ステップずつ追う
クライアント(サードパーティアプリ)が、ユーザーの連絡先を パスワードを受け取らずに読むまでの流れです。 各ステップが認証か認可か、 クライアントが何を受け取ったのかに注目してください。
「▶ 再生」または「次へ」でフローを開始してください。
ステップ 2 でユーザーがパスワードを入れる相手は認可サーバーであって、 クライアントではありません。クライアントが最後に手にするのは アクセストークン(連絡先 API を叩く許可証)であって、 「この人が誰か」の証明書ではない、という点が要点です。
② 「トークンでログイン」がなりすましを通す様子
ログインの判定方式を切り替えて、同じトークン置換攻撃の結末を見比べます。 攻撃者は、被害者 user-X 向けに発行された別アプリ(アプリA)用のトークンを手に入れ、 それをサービスB(アプリB)に突きつけます。
方式を選び、「正規ログイン」か「攻撃」を実行すると、結果がここに出ます。
素朴方式で「攻撃」を実行すると、aud(受け手)を確かめないため、 被害者向けのトークンをそのまま受け入れ、攻撃者が user-X になりすませます(赤)。 OIDC 方式は ID Token の aud が自分(アプリB)宛でないことを検出して拒否します(緑)。
認証と認可は別物 ―― パスポートとルームキー
まず言葉を分けます。認証(Authentication, AuthN)は「あなたは誰か」を確かめることです。 空港でパスポートを見せて本人だと示すのが認証です。一方 認可(Authorization, AuthZ)は「あなたは何をしてよいか」を与えることです。 ホテルのルームキーがこれにあたります。
ルームキーは、持っている人が誰であろうと、その部屋のドアを開けます。 フロントは鍵を渡すときに本人確認をしますが、鍵そのものは「この鍵を持つ者は入室してよい」という 許可を表すだけで、「持ち主が誰か」は語りません。落とした鍵を拾った人も部屋に入れてしまいます。
OAuth が発行するアクセストークンは、この「ルームキー型」です。仕様上は bearer token(持参人式トークン, RFC 6750)と呼ばれ、 「それを提示した者にアクセスを認める」性質を持ちます。だからトークン単体は「いま本人がそこにいる」ことを保証しません。
OAuth が本来解いている問題 ―― 権限の委譲
OAuth が生まれた動機は、ログインではなく権限の委譲です。 具体例で考えます。年賀状を作るアプリに、Google の連絡先を読み込ませたいとします。 ですが Google のパスワードをそのアプリに渡したくはありません。パスワードを渡せば、連絡先だけでなく メールも Drive も、何もかも触れてしまうからです。
OAuth はこの問題を解きます。ユーザーは Google(認可サーバー)に対して 「この年賀状アプリに、連絡先の読み取りだけを許可する」と同意します。 アプリが受け取るのは連絡先 API を叩くためのアクセストークンだけで、パスワードは渡りません。 これは「自分のリソースへのアクセス権を、パスワードを渡さずに第三者へ委譲する」という、 まさに認可の問題です。ログイン(認証)の問題ではありません。
認可コードフローを追う
上のデモAで再生したのが認可コードフロー(Authorization Code Flow)です。 登場人物は4者います。
- リソースオーナー: ユーザー本人。リソース(連絡先など)の持ち主。
- クライアント: サードパーティアプリ。リソースを使いたい側。
- 認可サーバー: 同意を取り、トークンを発行する(例: Google の認可エンドポイント)。
- リソースサーバー: API を持つサーバー。トークンを見てアクセスを許す。
流れはこうです。
- クライアントがユーザーを認可サーバーへリダイレクトする(欲しい権限を
scopeで指定)。 - ユーザーが認可サーバーで認証し、「このアプリに連絡先の読み取りを許可しますか?」に同意する。
- 認可サーバーが認可コード(一度きりの引換券)をリダイレクトでクライアントに返す。
- クライアントが裏側(サーバー間通信)で認可コードをアクセストークンに交換する(
client_secretを提示)。 - クライアントがアクセストークンを添えてリソースサーバーの API を叩く。
ここで大事なのは、各ステップが「認証」か「認可」かの切り分けです。 ステップ2でユーザーが認証するのは認可サーバーとユーザーの間の話であって、 クライアントはその認証に立ち会っていません。クライアントが最終的に受け取るのは アクセス許可(トークン)であり、「この人が誰かの証明」ではありません。 この一点が、次の落とし穴につながります。
なぜ「OAuth でログイン」が書けてしまうのか
アクセストークンを手にすると、多くの IdP では /userinfo のようなエンドポイントを叩けます。 すると、そのトークンに紐づくメールアドレスやユーザー ID が返ってきます。だから、つい次のような実装を書けてしまいます。
// アンチパターン: 認可を認証に流用する
const token = await exchangeCodeForAccessToken(code)
const profile = await fetch('/userinfo', { token }) // ← メールや sub が取れる
loginAs(profile.sub) // ← 取れたから本人とみなす 「トークンが取れた → /userinfo が引けた → この人はログイン成功」という筋です。 動いてしまうので、たちが悪いところです。ですがこれは 認可(アクセス許可)を認証(本人確認)に流用しているアンチパターンです。 トークンが取れたことは「このトークンで API を叩いてよい」ことしか意味せず、 「いま、この人が本人として認証された」ことを意味しません。
バッドケース: トークン置換でなりすます
素のアクセストークンには、「誰のために発行されたか(受け手 = audience)」を検証する手段が伴わないことがあります。 この隙を突くのがトークン置換(混乱した代理人 / confused deputy 問題)です。デモBで再生したのがこれです。
- 攻撃者が、悪意あるアプリ A を用意する。正規ユーザーをアプリ A で Google 認可させ、アプリ A 向けのアクセストークンを手に入れる(あるいは何らかの方法で漏れたトークンを入手する)。
- 攻撃者は、そのアクセストークンを、別の正規サービス B(「Google のアクセストークンでログインできます」式の実装)に突きつける。
- サービス B は「このトークンで
/userinfoが引けた。中のsubはユーザー X だ。よし、ユーザー X としてログインさせよう」と誤認する。
こうして、攻撃者がユーザー X になりすましてサービス B にログインできてしまいます。 要点はこうです ―― 「アクセストークンを持っている」=「本人が今ここで認証した」ではありません。 そのトークンは別アプリ向けのものかもしれませんし、過去に漏れたものかもしれません。 ルームキーを拾った人が部屋に入れてしまうのと同じ構図です。
/userinfo が引けた = 本人がいま認証された、とみなす。正しい解: OpenID Connect と ID Token の検証
ログインをやりたいなら、OAuth 2.0 の上に認証レイヤを乗せた仕様 ―― OpenID Connect(OIDC Core 1.0)を使います。OIDC は同じ認可コードフローに乗せつつ、 アクセストークンとは別に ID Token(JWT 形式)を発行します。
ID Token は署名付きの「この人は認証された」という証明書で、次のクレーム(中身)を持ちます。
| クレーム | 意味 | クライアントが検証すること |
|---|---|---|
iss | 発行者 | 期待する IdP(発行者)と一致するか |
sub | ユーザーの一意識別子 | これを本人 ID として使う |
aud | このトークンの受け手 | 自分のクライアント IDと一致するか |
exp | 失効時刻 | まだ切れていないか |
iat | 発行時刻 | 古すぎないか(必要に応じて) |
nonce | リプレイ防止用の値 | 認可リクエスト時に送った値と一致するか |
クライアントは ID Token の署名を検証し、iss が期待する発行者か、 aud が自分のクライアント ID か、exp が切れていないか、 nonce が一致するかを確かめてから、はじめて「この人は認証された」と判断します。 署名付きなので、途中で中身が書き換えられれば検知できます。
これで先のトークン置換が弾けます。攻撃者が突きつけるトークンはアプリ A 向けに発行されたものなので、 aud が「アプリ B のクライアント ID」と一致しません。サービス B は 「これは自分宛ではない」と検出して拒否します。デモB の OIDC 方式が緑で弾いたのは、この aud 検証です。
アクセストークン vs ID トークン ―― 用途の取り違え
混同しやすいので整理します。アクセストークンと ID トークンは、宛先も役割も違います。
| 観点 | アクセストークン | ID トークン |
|---|---|---|
| 役割 | API を叩くための鍵 | 「この人は認証された」という証明書 |
| 宛先(受け手) | リソースサーバー | クライアント自身 |
| クライアントの扱い | 中身を解釈しなくてよい(不透明でよい) | 署名・iss・aud・exp・nonce を検証してから使う |
| 何を表すか | 何をしてよいか(許可) | 誰であるか・いつ認証したか(本人性) |
| 取り違えると | ログイン判定に流用 → なりすましの穴 | API アクセスに流用 → 用途違いで通らない/危険 |
取り違えると事故ります。ID トークンを API のアクセスに使うのも、 アクセストークンをログイン判定に使うのも、どちらも用途違いです。 前者は宛先が違うため通らない/危険で、後者がまさに前章で見たなりすましの穴です。
「Google でログイン」と「カレンダー連携」を言い分ける
実務で言葉を分けると、設計の取り違えが減ります。
日常会話では両方まとめて「OAuth」と呼ばれがちです。ですがログイン用途は、正確には OIDC です。 「Sign in with Google」は認証、「Connect your Google Calendar」は委譲 ―― この区別を持っておくと、 「ログインなのにアクセストークンで本人確認しようとしていないか」に気づけます。
グッドケース / バッドケースのまとめ
/userinfo → その ID でログイン」。aud 検証なし。 トークン置換でなりすまし可能。 iss・aud・exp・nonceを検証してからセッションを張る。 素性のわかった IdP(Identity Provider)に本人確認を委ねる。 まとめ ―― 認可で本人確認をしない
OAuth 2.0 は認可のフレームワークです。アクセストークンは「持っている者が使える」許可証で、 「誰であるか」を語りません。だから「トークンが取れたからログイン成功」とみなす実装は、 認可を認証に流用しており、トークン置換でなりすまされる余地を残します。
ログインがやりたいなら、OAuth の上に立つ OIDC を使い、 発行された ID Token を検証してから「この人は認証された」と判断します。 aud が自分宛かを確かめるだけで、別アプリ向けトークンの突きつけは弾けます。 認可の道具で本人確認をしない ―― これが一行のまとめです。
出典・参考
- RFC 6749 — The OAuth 2.0 Authorization Framework
- RFC 6750 — The OAuth 2.0 Authorization Framework: Bearer Token Usage
- OpenID Connect Core 1.0(ID Token のクレームと検証手順)
- oauth.net «OAuth 2.0» / «User Authentication with OAuth 2.0»(OAuth は認証ではない、の解説)
- Auth0 / Okta による解説(Access Token と ID Token の違い、ID Token を API 認可に使わない)
本ページは上記の一次仕様・解説を、操作しながら学べる教材として再構成したものです。 デモのトークン検証・なりすまし判定は、要点を伝えるために簡略化したモデルです。