OAuth 2.0

3 行まとめ
Section titled “3 行まとめ”- OAuth 2.0 は認可の委譲フレームワーク。「利用者のパスワードをアプリに渡さずに、アプリへ限定的なアクセス権を渡す」ための仕組みで、仕様は RFC 6749。発行されたトークンの使い方は Bearer トークンの担当。
- 中核は認可コードフロー。認可の同意はブラウザ経由(フロントチャネル)で取り、トークンの受け取りはサーバー間(バックチャネル)でやる。この分離が OAuth の設計そのもの。
- RFC 6749 だけを読むと現在の実務と食い違う。 implicit グラントは SHOULD NOT、パスワードグラントは MUST NOT、PKCE は public クライアントに MUST — これらは 2025 年の RFC 9700 が RFC 6749 を更新して定めた。
当たるべき文書
Section titled “当たるべき文書”OAuth は 1 本の RFC で完結していない。 「RFC 6749 に準拠」だけでは現行の要求を満たさない。
| 文書 | 位置づけ | この記事で扱う範囲 |
|---|---|---|
| RFC 6749 | 本体(2012、Proposed Standard)。RFC 5849 (OAuth 1.0) を廃止 | ロール、認可コードフロー、エラー |
| RFC 9700 | BCP 240(2025)。RFC 6749 / 6750 / 6819 を更新する | 現行の可否判断はすべてここが基準 |
| RFC 7636 | PKCE(2015) | code_verifier と code_challenge |
| RFC 8252 | BCP 212。ネイティブアプリ | リダイレクト先の選択肢、WebView 禁止 |
| RFC 9207 | 認可サーバーの識別(iss) |
mix-up 攻撃対策 |
| RFC 8414 | 認可サーバーメタデータ | /.well-known/ からの設定取得 |
| RFC 9068 | access token を JWT にする profile | at+jwt と必須クレーム |
| OIDC Core 1.0 | OAuth の上に載る認証レイヤ | id_token との違い |
4 つのロール
Section titled “4 つのロール”OAuth が置き換えたのは「利用者がサービス A のパスワードをサービス B に教える」という運用だった。

渡すものが「全権の鍵束」から「1 室だけ開く期限付きのカードキー」に変わる。 この置き換えを成立させるために、OAuth は登場人物を 4 つに分解した。
RFC 6749 §1.1 が定義するロールは 4 つ。「クライアント」がユーザーの端末ではなく アプリケーションを指すことが、最初のつまずきどころ。
| ロール | RFC 6749 §1.1 の定義 | 具体例 |
|---|---|---|
| resource owner | “An entity capable of granting access to a protected resource” | 写真を持っている利用者 |
| resource server | “The server hosting the protected resources” | 写真 API |
| client | “An application making protected resource requests on behalf of the resource owner” | 印刷サービス |
| authorization server | “The server issuing access tokens to the client” | 写真サービスの認可サーバー |
flowchart TB
RO["リソースオーナー<br/>(利用者)"]
C["クライアント<br/>(印刷サービス)"]
AS["認可サーバー<br/>(トークンを発行する)"]
RS["リソースサーバー<br/>(写真 API)"]
RO -->|"1. 同意する<br/>パスワードは渡さない"| AS
C -->|"2. 認可コードを交換"| AS
AS -->|"3. access token"| C
C -->|"4. Authorization: Bearer"| RS
RS -->|"5. 写真を返す"| C
style AS fill:#4054b2,color:#fff
style RO fill:#1f5c33,color:#fff
resource server と authorization server は別のサーバーでよい(同一でもよい)。 分かれているからこそ「トークンを発行する場所」と「トークンを検証して使わせる場所」を 別チームで運用できる。この分離は Bearer トークンの記事で扱った 「発行と提示は別のサーバーで起きる」と同じ構図。
public クライアントと confidential クライアント
Section titled “public クライアントと confidential クライアント”この区別がフロー選択のすべてを決める。 RFC 6749 §2.1。
| 種別 | 定義 | 例 | 秘密鍵を持てるか |
|---|---|---|---|
| confidential | “capable of maintaining the confidentiality of their credentials” | サーバーサイド Web アプリ | 持てる |
| public | “incapable of maintaining the confidentiality of their credentials” | SPA、ネイティブアプリ | 持てない |
SPA もネイティブアプリも、配布したバイナリや JavaScript を利用者が読める以上、
client_secret を埋め込んでも秘密にならない。だから public に分類され、
client_secret の代わりに PKCE で「同じクライアントが取りに来た」ことを証明する。
認可コードフロー
Section titled “認可コードフロー”RFC 6749 §4.1。この 1 枚が試験でも実務でも一番問われる。
sequenceDiagram
autonumber
participant UA as 利用者 (ブラウザ)
participant C as クライアント
participant AS as 認可サーバー
participant RS as リソースサーバー
UA->>C: サービスを使い始める
C->>C: code_verifier を生成<br/>challenge を計算
C-->>UA: 302 認可エンドポイントへ
UA->>AS: GET /authorize<br/>client_id / redirect_uri<br/>state / code_challenge
AS-->>UA: ログイン画面と同意画面
UA->>AS: 認可サーバー上でログイン<br/>scope に同意
AS-->>UA: 302 redirect_uri へ<br/>code / state / iss
UA->>C: GET /callback<br/>code と state
C->>C: state を照合する
C->>AS: POST /token<br/>code と code_verifier<br/>クライアント認証
AS->>AS: challenge と verifier を<br/>突き合わせる
AS-->>C: access_token / expires_in<br/>refresh_token
C->>RS: Authorization: Bearer ...
RS-->>C: 保護されたリソース
利用者のパスワードは 6 番でしか流れず、しかも認可サーバーにしか渡らない。 クライアントはパスワードを一度も見ない。これが OAuth が解いた問題そのもの。 ログイン画面を出すのは認可サーバー自身であって、クライアントではない。
フロントチャネルとバックチャネル
Section titled “フロントチャネルとバックチャネル”OAuth のデータフローを理解する鍵はここにある。 値が「ブラウザを通る」か 「サーバー同士で直接やりとりされる」かで、危険度がまったく違う。
flowchart TB
subgraph FRONT["フロントチャネル (ブラウザ経由・URL に載る)"]
F1["client_id / redirect_uri"]
F2["state / code_challenge"]
F3["認可コード code"]
end
subgraph BACK["バックチャネル (サーバー間・TLS 直結)"]
B1["client_secret"]
B2["code_verifier"]
B3["access_token / refresh_token"]
end
FRONT -->|"code だけを渡す"| BACK
F3 -.->|"履歴 / Referer / ログ<br/>プロキシに残りうる"| RISK["漏れる前提で設計する"]
BACK --> SAFE["ブラウザには一切出ない"]
style RISK fill:#7a2222,color:#fff
style SAFE fill:#1f5c33,color:#fff
style BACK fill:#4054b2,color:#fff
| フロントチャネル | バックチャネル | |
|---|---|---|
| 経路 | 利用者のブラウザのリダイレクト | クライアントのサーバー → 認可サーバー |
| 載る場所 | URL のクエリ文字列 | HTTP ボディ(application/x-www-form-urlencoded) |
| 残る場所 | ブラウザ履歴、Referer、アクセスログ、プロキシ |
どこにも残らない |
| 通る値 | code(短命・1 回限り) |
access_token、refresh_token、client_secret |
| 前提 | 漏れうる | 漏れない |
認可コードフローが安全なのは、漏れうる経路に「短命で 1 回しか使えず、単体では トークンに換えられない値」しか流さないから。 implicit グラントが廃れたのは、 この設計を捨てて access token そのものをフロントチャネルに載せたため。
データフローを追う
Section titled “データフローを追う”実際の値で追う。ドメインは app.example.com(クライアント)と
as.example.com(認可サーバー)とする。client_id と code は RFC 6749 の例示値、
PKCE の値はこの記事のために実際に計算したもの。
1. code_verifier を作り、code_challenge を計算する
Section titled “1. code_verifier を作り、code_challenge を計算する”クライアントは認可リクエストを組み立てる前にランダムな code_verifier を作る。
# base64url = base64 の + / を - _ に置き換え、パディング = を外したものb64url() { base64 | tr '+/' '-_' | tr -d '=' ; }
# 32 バイトの乱数 → base64url でちょうど 43 文字(RFC 7636 の下限)code_verifier=$(openssl rand 32 | b64url)echo "$code_verifier"# PR-cCntF4taPMakEx75SENhDf26hyVsC59Qxim8LksU
# S256: BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))code_challenge=$(printf '%s' "$code_verifier" | openssl dgst -binary -sha256 | b64url)echo "$code_challenge"# sOiV9ZgIX37UdtxEUzYQz7zKdaDeBwRb8ilMwpFVg4cprintf '%s' を使う。echo だと末尾に改行が入り、ハッシュがまるごと変わる。
ここは実際に踏みやすい。
値の対応:
| 段階 | 値 | 長さ |
|---|---|---|
code_verifier |
PR-cCntF4taPMakEx75SENhDf26hyVsC59Qxim8LksU |
43 文字 |
| SHA-256(16 進) | b0e895f598085f7ed476dc44533610cfbcca75a0de07045bf2294cc291558387 |
64 桁 |
code_challenge(S256) |
sOiV9ZgIX37UdtxEUzYQz7zKdaDeBwRb8ilMwpFVg4c |
43 文字 |
code_verifier はクライアントの中に留まり、フロントチャネルには出ない。
ブラウザに出るのはハッシュした code_challenge だけ。
2. 認可リクエスト(フロントチャネル)
Section titled “2. 認可リクエスト(フロントチャネル)”RFC 6749 §4.1.1 + RFC 7636 §4.3。ブラウザをこの URL へリダイレクトさせる。
GET /authorize?response_type=code &client_id=s6BhdRkqt3 &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback &scope=photo.read &state=mkHXw-BfKLY &code_challenge=sOiV9ZgIX37UdtxEUzYQz7zKdaDeBwRb8ilMwpFVg4c &code_challenge_method=S256 HTTP/1.1Host: as.example.com(実際は 1 行。読みやすさのために折り返している)
| パラメータ | 要否 | 意味 |
|---|---|---|
response_type |
REQUIRED | 認可コードフローでは “MUST be set to code” |
client_id |
REQUIRED | 事前登録したクライアント識別子 |
redirect_uri |
OPTIONAL | 登録済みのものと完全一致である必要がある(後述) |
scope |
OPTIONAL | 要求する権限。リソース単位で最小に切る |
state |
RECOMMENDED | “SHOULD be used for preventing cross-site request forgery” |
code_challenge |
REQUIRED (PKCE) | 上で計算したハッシュ |
code_challenge_method |
OPTIONAL | 省略すると plain 扱いになる。必ず S256 を明示する |
3. 同意して、リダイレクトで code が返る
Section titled “3. 同意して、リダイレクトで code が返る”認可サーバーは利用者を認証し、scope の同意を取ってから redirect_uri へ戻す。
利用者が見るのはこの画面で、scope がそのまま同意項目として並ぶ。

同意画面は認可サーバーが出す。クライアントは描画にも入力にも関与しない。
scope を粗く切ると、利用者は「全権を渡すか使わないか」の二択になり、同意が形骸化する。
HTTP/1.1 302 FoundLocation: https://app.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA &state=mkHXw-BfKLY &iss=https%3A%2F%2Fas.example.comcode— RFC 6749 §4.1.2。“MUST expire shortly after it is issued”。 仕様の推奨する上限は 10 分。そして 1 回しか使えないstate— “The exact value received from the client”。クライアントは送った値と照合するiss— RFC 9207。この認可レスポンスがどの認可サーバーから来たかを示す
4. code をトークンに交換する(バックチャネル)
Section titled “4. code をトークンに交換する(バックチャネル)”ここからブラウザは一切関与しない。 クライアントのサーバーが認可サーバーを直接叩く。
POST /token HTTP/1.1Host: as.example.comContent-Type: application/x-www-form-urlencodedAuthorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&code_verifier=PR-cCntF4taPMakEx75SENhDf26hyVsC59Qxim8LksUAuthorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW は
s6BhdRkqt3:gX1fBat3bV を Base64 したもの。クライアント認証は
Basic 認証そのもので、ここでも「Base64 は暗号化ではない」が効く。
public クライアントはこのヘッダを持てないので、代わりに client_id をボディに入れる。
redirect_uri— 認可リクエストに含めていたなら REQUIRED。 “Values MUST be identical”。認可時と交換時で違えばエラーcode_verifier— RFC 7636 §4.5 で REQUIRED
5. 認可サーバー側の検証
Section titled “5. 認可サーバー側の検証”flowchart TB
IN["POST /token を受ける"]
C1{"code は存在し<br/>未使用で期限内か"}
C2{"code はこの client_id に<br/>発行したものか"}
C3{"redirect_uri は<br/>認可時と完全一致か"}
C4{"S256 の再計算が<br/>code_challenge と一致するか"}
OK["access_token を発行<br/>code を使用済みにする"]
NG["400 invalid_grant"]
IN --> C1
C1 -->|"いいえ"| NG
C1 -->|"はい"| C2
C2 -->|"いいえ"| NG
C2 -->|"はい"| C3
C3 -->|"いいえ"| NG
C3 -->|"はい"| C4
C4 -->|"いいえ"| NG
C4 -->|"はい"| OK
style OK fill:#1f5c33,color:#fff
style NG fill:#7a2222,color:#fff
RFC 7636 §4.6 は S256 のとき
BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) == code_challenge を検証し、
不一致なら “an error response indicating invalid_grant … MUST be returned” と定める。
6. トークンレスポンス
Section titled “6. トークンレスポンス”{ "access_token": "2YotnFZFEjr1zCsicMWpAA", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "scope": "photo.read"}token_type が Bearer なので、ここから先は
Bearer トークンの記事の話に接続する。
Cache-Control: no-store を付けるのが仕様の要求。
7. code が盗まれたら何が起きるか
Section titled “7. code が盗まれたら何が起きるか”PKCE の価値はこの 1 枚に集約される。 ネイティブアプリで カスタム URI スキームを別のアプリが横取りした場面を想定する。
sequenceDiagram
autonumber
participant V as 正規アプリ
participant OS as OS / ブラウザ
participant M as 悪意あるアプリ
participant AS as 認可サーバー
V->>AS: /authorize + code_challenge
AS-->>OS: 302 myapp://cb?code=Splxl...
OS->>M: URI スキームを横取り
M->>AS: POST /token<br/>code=Splxl...<br/>code_verifier が作れない
AS-->>M: 400 invalid_grant
Note over M,AS: PKCE がなければ<br/>ここで access_token が渡っていた
| PKCE なし | PKCE あり (S256) | |
|---|---|---|
| 攻撃者が持つもの | code |
code |
| 交換に必要なもの | code のみ |
code + code_verifier |
code_verifier の入手 |
— | ハッシュからは戻せない |
| 結果 | access_token を奪われる | invalid_grant で失敗 |
RFC 7636。読みは「ピクシー」。 “Proof Key for Code Exchange” — コード交換のための証明鍵。
code_verifier の規格
Section titled “code_verifier の規格”code-verifier = 43*128unreservedunreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"- 43 文字以上 128 文字以下(RFC 7636 §4.1)
- 使える文字は base64url の安全な文字集合
- 高エントロピーな暗号論的乱数であること。連番やタイムスタンプは不可
43 という下限は、32 バイトの乱数を base64url にすると 43 文字になることから来ている。
openssl rand 32 を base64url するのがそのまま最小構成になる。
S256 と plain
Section titled “S256 と plain”| method | 変換 | 使ってよいか |
|---|---|---|
S256 |
BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) |
これを使う |
plain |
code_challenge = code_verifier |
実質使わない |
RFC 7636 §4.2 は明確に
“If the client is capable of using S256, it MUST use S256” と定め、
S256 はサーバー側で Mandatory To Implement。
plain は code_verifier をそのままフロントチャネルに載せるので、
認可リクエストを覗ける相手には PKCE の効果がまったくない。
「PKCE はモバイルアプリだけのもの」は誤り
Section titled “「PKCE はモバイルアプリだけのもの」は誤り”RFC 9700 §2.1.1 は “Public clients MUST use PKCE” と定める。 SPA も public クライアントなので対象。さらに同じ節が “For confidential clients, the use of PKCE is RECOMMENDED” としており、 サーバーサイド Web アプリでも推奨される。
confidential クライアントでも推奨される理由は、PKCE が
クライアント認証とは別の層で「認可リクエストを出した本人か」を保証するから。
client_secret が漏れても、code_verifier を知らなければ code は使えない。
state・PKCE・iss がそれぞれ防ぐもの
Section titled “state・PKCE・iss がそれぞれ防ぐもの”3 つとも「認可レスポンスを信じてよいか」に関わるが、防ぐ攻撃が違う。 名前が似ているので取り違えやすく、試験でもよく問われる。
flowchart TB
Q["/callback に来たレスポンスを<br/>信じてよいか"]
S["state<br/>RFC 6749 §10.12"]
P["PKCE<br/>RFC 7636"]
I["iss<br/>RFC 9207"]
Q --> S
Q --> P
Q --> I
S --> SA["自分が始めたフローか<br/>(CSRF 対策)"]
P --> PA["自分が出した認可要求か<br/>(code 横取り対策)"]
I --> IA["どの認可サーバーから来たか<br/>(mix-up 対策)"]
style S fill:#4054b2,color:#fff
style P fill:#4054b2,color:#fff
style I fill:#4054b2,color:#fff
| 仕組み | 防ぐ攻撃 | 照合するもの |
|---|---|---|
state |
CSRF(他人のアカウントを紐づけられる) | セッションに保存した値と一致するか |
| PKCE | 認可コード横取り | code_verifier のハッシュが code_challenge と一致するか |
iss |
mix-up(複数の認可サーバーを扱うとき混同させられる) | 期待した issuer と一致するか |
「state だけで CSRF 対策は十分」は不正確
Section titled “「state だけで CSRF 対策は十分」は不正確”RFC 6749 §10.12 の state は SHOULD であって MUST ではない。
そして RFC 9700 §2.1.3 / §4.7.1 は
“Clients that have ensured that the authorization server supports PKCE MAY rely on
the CSRF protection provided by PKCE” と述べ、PKCE が state の CSRF 対策を代替できる
としている。
実務上の結論は単純: PKCE は必ず入れる。state はアプリ側の状態
(ログイン後の戻り先など)を運ぶ用途でも要るので、併用して損はない。
mix-up 攻撃と iss
Section titled “mix-up 攻撃と iss”複数の認可サーバーに対応するクライアント(「Google でログイン」「GitHub でログイン」)で、 攻撃者が片方の認可サーバーで得た code を、もう片方向けのコールバックに投げ込む攻撃。 クライアントが「どの認可サーバーへのリクエストだったか」を覚えていないと、 攻撃者の認可サーバーが発行した code を正規サーバーのトークンエンドポイントへ送ってしまう。
RFC 9207 §2.4 は、クライアントは iss を取り出して issuer 識別子と比較し、
不一致なら “MUST reject the authorization response and MUST NOT proceed” と定める。
RFC 9700 §2.1.4 も iss の使用を SHOULD としている。
redirect_uri は完全一致でなければならない
Section titled “redirect_uri は完全一致でなければならない”RFC 9700 §2.1 の要求は厳しい。
authorization servers MUST utilize exact string matching except for port numbers in
localhostredirection URIs of native apps
前方一致もワイルドカードも認められない。https://app.example.com/callback を登録したら、
https://app.example.com/callback?x=1 も https://app.example.com/callback/ も別物として弾く。
| 書き方 | 可否 | 理由 |
|---|---|---|
https://app.example.com/callback を完全一致で登録 |
可 | 仕様どおり |
https://app.example.com/* |
不可 | オープンリダイレクタがあれば code を転送できる |
https://*.example.com/callback |
不可 | サブドメイン乗っ取りで code を奪える |
http://127.0.0.1:8080/callback のポート可変 |
可(ネイティブのみ) | 仕様が明示する唯一の例外 |
ネイティブアプリのリダイレクト先
Section titled “ネイティブアプリのリダイレクト先”RFC 8252 が 3 つの選択肢を挙げる。
| 方式 | 例 | 評価 |
|---|---|---|
claimed https スキーム |
https://app.example.com/cb |
“SHOULD use them over the other options where possible”(§7.2)。OS が所有権を検証する |
| private-use URI スキーム | com.example.app:/cb |
“MUST use a URI scheme based on a domain name under their control, expressed in reverse order”(§7.1)。横取りされうる |
| ループバック | http://127.0.0.1:8080/cb |
デスクトップアプリ向け(§7.3) |
グラントの現在地
Section titled “グラントの現在地”RFC 6749 は 4 つのグラントを定義したが、そのうち 2 つは RFC 9700 で使うなと言われている。
| グラント | RFC 6749 での位置 | RFC 9700 での扱い |
|---|---|---|
| authorization code | §4.1 | これを使う(+ PKCE) |
| implicit | §4.2 | SHOULD NOT(§2.1.2) |
| resource owner password credentials | §4.3 | MUST NOT(§2.4) |
| client credentials | §4.4 | 有効。利用者が介在しないサーバー間通信専用 |
RFC 9700 §2.1.2 の原文は “clients SHOULD NOT use the implicit grant … or other response types issuing access tokens in the authorization response”。 access token をフロントチャネルに載せる方式すべてが対象になる。
§2.4 は “The resource owner password credentials grant MUST NOT be used”。 利用者の ID とパスワードをクライアントに直接入力させる方式は禁止で、 これは OAuth が最初に解こうとした問題そのものに逆戻りする設計だから。
flowchart TB
START["どのフローを使うか"]
Q1{"利用者が<br/>介在するか"}
Q2{"クライアントは<br/>secret を守れるか"}
Q3{"入力デバイスが<br/>貧弱か<br/>(TV / CLI)"}
CC["client credentials"]
ACC["authorization code<br/>+ PKCE<br/>+ client_secret"]
ACP["authorization code<br/>+ PKCE"]
DEV["device authorization grant<br/>(RFC 8628)"]
START --> Q1
Q1 -->|"いいえ"| CC
Q1 -->|"はい"| Q3
Q3 -->|"はい"| DEV
Q3 -->|"いいえ"| Q2
Q2 -->|"はい (サーバーサイド)"| ACC
Q2 -->|"いいえ (SPA / ネイティブ)"| ACP
style ACC fill:#1f5c33,color:#fff
style ACP fill:#1f5c33,color:#fff
style CC fill:#4054b2,color:#fff
style DEV fill:#4054b2,color:#fff
エンドポイントごとにエラーコードの集合が違う。 混ぜて覚えると事故る。
認可エンドポイント(RFC 6749 §4.1.2.1)
Section titled “認可エンドポイント(RFC 6749 §4.1.2.1)”エラーはリダイレクト先に返す。認可サーバーがエラー画面を出すのではない。
error |
起きる場面 |
|---|---|
invalid_request |
必須パラメータ欠落、重複 |
unauthorized_client |
このクライアントにこの response_type を許していない |
access_denied |
利用者が同意を拒否した |
unsupported_response_type |
code 以外を要求された |
invalid_scope |
未知・不正な scope |
server_error |
認可サーバー内部エラー(500 を返せないため) |
temporarily_unavailable |
一時的な過負荷(503 を返せないため) |
server_error と temporarily_unavailable が存在するのは、
リダイレクトで返す以上 HTTP ステータスで表現できないから。
トークンエンドポイント(RFC 6749 §5.2)
Section titled “トークンエンドポイント(RFC 6749 §5.2)”こちらは HTTP ステータス(400 / 401)と JSON で返す。
error |
起きる場面 |
|---|---|
invalid_request |
パラメータ欠落・重複 |
invalid_client |
クライアント認証に失敗(401、WWW-Authenticate を付ける) |
invalid_grant |
code が無効・期限切れ・使用済み、redirect_uri 不一致、PKCE 不一致 |
unauthorized_client |
このクライアントにこの grant_type を許していない |
unsupported_grant_type |
未対応の grant_type |
invalid_scope |
元の grant より広い scope を要求した |
invalid_grant が最頻出。 RFC 6749 §5.2 の定義には
“does not match the redirection URI used in the authorization request, or was issued to another client”
が含まれ、PKCE の不一致もここに集約される。エラーメッセージが同じなので、
原因の切り分けは認可サーバー側のログでやるしかない。
OpenID Connect との違い
Section titled “OpenID Connect との違い”OAuth 2.0 は認可、OpenID Connect は認証。 OIDC Core 1.0 §1 は自身を “a simple identity layer on top of the OAuth 2.0 protocol” と説明する。
| access token | ID token | |
|---|---|---|
| 仕様 | RFC 6749 / 6750 | OIDC Core 1.0 |
| 答える問い | このアプリは何をしてよいか | 誰がいつ認証されたか |
| 形式 | 不透明でも JWT でもよい | 必ず JWT(署名付き) |
| 宛先 | リソースサーバー | クライアント自身 |
| 中身を読むのは | リソースサーバー | クライアント |
OIDC を有効にするのは scope に openid を入れるだけ。
OIDC Core §3.1.2.1 は “OpenID Connect requests MUST contain the openid scope value”。
scope=openid profile emailすると、トークンレスポンスに id_token が増える。
| 追加される要素 | 役割 |
|---|---|
id_token |
認証の証跡。iss / sub / aud / exp / iat を含む JWT |
nonce |
“used to associate a Client session with an ID Token, and to mitigate replay attacks”(§3.1.2.1) |
| UserInfo エンドポイント | access token を Bearer で提示して利用者情報を取る(§5.3) |
認可サーバーメタデータ
Section titled “認可サーバーメタデータ”RFC 8414。エンドポイントの URL をハードコードしない。
curl -s https://as.example.com/.well-known/oauth-authorization-server | jq{ "issuer": "https://as.example.com", "authorization_endpoint": "https://as.example.com/authorize", "token_endpoint": "https://as.example.com/token", "jwks_uri": "https://as.example.com/jwks.json", "response_types_supported": ["code"], "code_challenge_methods_supported": ["S256"], "revocation_endpoint": "https://as.example.com/revoke", "introspection_endpoint": "https://as.example.com/introspect"}issuer と response_types_supported が REQUIRED。
code_challenge_methods_supported に S256 があるかを起動時に確認するのが、
RFC 9700 が言う「認可サーバーが PKCE をサポートしていることを確認したクライアント」の実装。
OIDC は別のパス(/.well-known/openid-configuration)を使う。取り違えやすい。
トークンの失効
Section titled “トークンの失効”| 仕様 | エンドポイント | 用途 |
|---|---|---|
| RFC 7009 | revocation_endpoint |
トークンを失効させる |
| RFC 7662 | introspection_endpoint |
トークンが有効か問い合わせる |
# 失効curl -s -u s6BhdRkqt3:gX1fBat3bV https://as.example.com/revoke \ -d token=tGzv3JOkF0XG5Qx2TlKWIA -d token_type_hint=refresh_token
# 有効性の確認(レスポンスの必須フィールドは active だけ)curl -s -u s6BhdRkqt3:gX1fBat3bV https://as.example.com/introspect \ -d token=2YotnFZFEjr1zCsicMWpAA使ってよいケースと使ってはいけないケース
Section titled “使ってよいケースと使ってはいけないケース”「OAuth は安全か」ではなく「委譲すべき境界があるか」で判断する。 境界がないところに OAuth を入れると、複雑さだけが増える。
成立の前提条件
Section titled “成立の前提条件”- リソースの所有者と、アクセスしたいアプリが別の主体である(委譲する相手がいる)
- 認可サーバーとリソースサーバーが TLS で公開されている
-
redirect_uriを完全一致で事前登録できる(ワイルドカードを要求しない) - クライアントが public か confidential かを判別できている
- 認可サーバーとクライアントの両方が PKCE の
S256を使える -
scopeをリソース単位で細かく切れる(全権トークンを作らずに済む) - トークンの有効期限・失効・ローテーションを運用できる
- 「誰がログインしたか」が要るなら OIDC を併用する用意がある
ひとつでも欠けたら別の方式を検討する。 特に 1 つ目が欠けている (自社の 1 つの Web アプリに自社のログインを付けたいだけ)なら、 Cookie とセッションのほうが素直で安全。

試験前に見返すならこの 1 枚。 どれが欠けているかで、採用すべき方式が決まる。
コーラルで示した redirect_uri の完全一致は、現場でいちばん破られている条件。
使うかどうかの判定
Section titled “使うかどうかの判定”flowchart TB
Q1{"アクセスさせたい先は<br/>別の主体が持つ<br/>リソースか"}
Q2{"利用者に同意を<br/>求める必要があるか"}
Q3{"PKCE S256 を<br/>両側で使えるか"}
Q4{"redirect_uri を<br/>完全一致で<br/>登録できるか"}
OK["authorization code<br/>+ PKCE を使う"]
NG1["セッション Cookie で足りる<br/>OAuth は過剰"]
NG2["client credentials か<br/>API キーを使う"]
NG3["認可サーバーを<br/>先に直す"]
Q1 -->|"いいえ"| NG1
Q1 -->|"はい"| Q2
Q2 -->|"いいえ"| NG2
Q2 -->|"はい"| Q3
Q3 -->|"いいえ"| NG3
Q3 -->|"はい"| Q4
Q4 -->|"いいえ"| NG3
Q4 -->|"はい"| OK
style OK fill:#1f5c33,color:#fff
style NG1 fill:#7a2222,color:#fff
style NG2 fill:#7a2222,color:#fff
style NG3 fill:#7a2222,color:#fff
使ってよいケース
Section titled “使ってよいケース”| ケース | 成立する理由 | 一緒にやること |
|---|---|---|
| サードパーティ製アプリに自社 API を開放する | 委譲の境界が明確。利用者のパスワードを渡さずに済む | scope を機能単位で切る。失効の窓口を用意する |
| SPA / ネイティブアプリから自社 API を呼ぶ | フロントチャネルに token を出さずに済む | PKCE 必須。refresh token はローテーション |
| 「Google でログイン」を実装する | 認可サーバーの運用を委ねられる | OIDC の id_token を使う。access token で認証しない |
| マイクロサービス間の呼び出し | 利用者が介在しないので client credentials が使える | aud をサービスごとに分ける。有効期限を短く |
| 社内 SaaS を SSO でまとめる | 認可サーバーが 1 つで済み、失効も一元化できる | メタデータを起動時に取得。iss を検証 |
使ってはいけないケース
Section titled “使ってはいけないケース”| ケース | できないこと |
|---|---|
| implicit グラントを新規に採用する | RFC 9700 §2.1.2 が SHOULD NOT。access token がフロントチャネルに出る |
| パスワードグラントでログイン画面を自作する | RFC 9700 §2.4 が MUST NOT。パスワードをクライアントが見てしまう |
| WebView で認可画面を開く | RFC 8252 §8.12 が MUST NOT。利用者が本物の画面か確認できない |
| access token をログイン判定に使う | 他アプリ向けのトークンで通ってしまう。id_token を使う |
redirect_uri をワイルドカードで登録する |
RFC 9700 §2.1 が完全一致を MUST。オープンリダイレクタで code が漏れる |
| 単一の自社 Web アプリのログイン | 委譲の境界がない。セッション Cookie のほうが単純で事故りにくい |
code_challenge_method を省略する |
plain にフォールバックし、PKCE が実質無効になる |
| 認可サーバーを自前でフルスクラッチする | 仕様の分量が多すぎる。実装済みの製品を使う |
「複雑だから避ける」ではなく、成熟した実装を使えば運用できる領域になっている。
| 種別 | 例 |
|---|---|
| SaaS | Auth0、Okta、Microsoft Entra ID、Amazon Cognito |
| セルフホスト | Keycloak、Ory Hydra、Authelia |
| ライブラリ | openid-client(Node.js)、authlib(Python)、Spring Authorization Server |
code_verifier から認可 URL までをシェルで組み立てる
Section titled “code_verifier から認可 URL までをシェルで組み立てる”AS=https://as.example.comCLIENT_ID=s6BhdRkqt3REDIRECT=https://app.example.com/callback
b64url() { base64 | tr '+/' '-_' | tr -d '=' ; }code_verifier=$(openssl rand 32 | b64url)code_challenge=$(printf '%s' "$code_verifier" | openssl dgst -binary -sha256 | b64url)state=$(openssl rand 8 | b64url)
python3 - <<PYimport urllib.parseq = urllib.parse.urlencode({ "response_type": "code", "client_id": "$CLIENT_ID", "redirect_uri": "$REDIRECT", "scope": "photo.read", "state": "$state", "code_challenge": "$code_challenge", "code_challenge_method": "S256",})print("$AS/authorize?" + q)PYcode をトークンに交換する
Section titled “code をトークンに交換する”curl -s -X POST "$AS/token" \ -u "$CLIENT_ID:gX1fBat3bV" \ -d grant_type=authorization_code \ -d code=SplxlOBeZQQYbYS6WxSbIA \ --data-urlencode "redirect_uri=$REDIRECT" \ -d "code_verifier=$code_verifier" | jqpublic クライアントは -u の代わりに -d client_id=$CLIENT_ID を使う。
Express で認可コードを受け取って交換する
Section titled “Express で認可コードを受け取って交換する”import express from 'express';import crypto from 'node:crypto';import session from 'express-session';
const app = express();app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
const AS = 'https://as.example.com';const CLIENT_ID = 's6BhdRkqt3';const CLIENT_SECRET = process.env.CLIENT_SECRET;const REDIRECT_URI = 'https://app.example.com/callback';
const b64url = (buf) => buf.toString('base64url');
app.get('/login', (req, res) => { const codeVerifier = b64url(crypto.randomBytes(32)); const codeChallenge = b64url(crypto.createHash('sha256').update(codeVerifier).digest()); const state = b64url(crypto.randomBytes(16));
// セッションに保存する。Cookie やローカル変数に置かない req.session.codeVerifier = codeVerifier; req.session.state = state;
const url = new URL(`${AS}/authorize`); url.search = new URLSearchParams({ response_type: 'code', client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, scope: 'photo.read', state, code_challenge: codeChallenge, code_challenge_method: 'S256', }).toString();
res.redirect(url.toString());});
app.get('/callback', async (req, res) => { const { code, state, iss, error } = req.query;
// 1. 認可サーバーからのエラーを先に処理する if (error) return res.status(400).send(`authorization failed: ${error}`);
// 2. state を照合する(タイミング安全比較を使う) const expected = req.session.state; const got = Buffer.from(String(state ?? '')); const want = Buffer.from(expected ?? ''); // timingSafeEqual は長さが違うと例外を投げるので、長さを先に見る if (!expected || got.length !== want.length || !crypto.timingSafeEqual(got, want)) { return res.status(400).send('state mismatch'); }
// 3. iss を検証する (RFC 9207) if (iss && iss !== AS) return res.status(400).send('issuer mismatch');
// 4. 使い終わった値は即座に捨てる const codeVerifier = req.session.codeVerifier; delete req.session.state; delete req.session.codeVerifier;
// 5. バックチャネルで交換する const r = await fetch(`${AS}/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'), }, body: new URLSearchParams({ grant_type: 'authorization_code', code: String(code), redirect_uri: REDIRECT_URI, // 認可時と完全に同じ文字列 code_verifier: codeVerifier, }), });
if (!r.ok) { const err = await r.json(); // invalid_grant がここに出る return res.status(400).send(`token request failed: ${err.error}`); }
const token = await r.json(); req.session.accessToken = token.access_token; // ブラウザには返さない res.redirect('/');});押さえる点は 4 つ。
code_verifierとstateはサーバー側セッションに置く。Cookie に入れると ブラウザから読めてしまい、PKCE の意味が薄れるstateの比較はtimingSafeEqual。文字列===はタイミング差が出るredirect_uriは認可時と 1 バイトも違えてはいけない。定数にするaccess_tokenをブラウザへ返さない。サーバー側セッションに保持して、 API 呼び出しはサーバーが代行する(BFF パターン)
運用で押さえる点
Section titled “運用で押さえる点”scope の切り方
Section titled “scope の切り方”「読み取り」と「書き込み」を最低限分ける。 1 つの full_access を作ると、
利用者は同意画面で全権を渡すか使わないかの二択になり、実質的に同意が形骸化する。
photo.read 写真の一覧と取得photo.write 写真のアップロードphoto.delete 写真の削除RFC 9700 はリソースサーバーごとに access token を分ける方向を推している。 1 枚のトークンが漏れたときの被害範囲が、そのまま scope の広さになる。
有効期限の目安
Section titled “有効期限の目安”| 値 | 目安 | 根拠 |
|---|---|---|
| 認可コード | 10 分以内(1 分程度が実用的) | RFC 6749 §4.1.2 が 10 分を RECOMMENDED |
| access token | 5 分 〜 1 時間 | 失効が効かない前提で短くする |
| refresh token | 数日 〜 数か月 | public クライアントはローテーション必須 |
refresh token の扱い
Section titled “refresh token の扱い”RFC 9700 §2.2.2:
Refresh tokens for public clients MUST be sender-constrained or use refresh token rotation
ローテーションは、refresh token を使うたびに新しいものを発行し、古いものを失効させる方式。 古い refresh token が再使用されたら盗まれた証拠なので、そのファミリー全体を失効させる。
sender-constrained(mTLS / DPoP)については Bearer トークンの記事で扱っている。
ログに残してはいけない値
Section titled “ログに残してはいけない値”| 値 | 扱い |
|---|---|
code |
URL に載るのでアクセスログに残る。 短命にして被害を抑える |
access_token / refresh_token |
絶対に残さない。ヘッダごとマスクする |
client_secret |
絶対に残さない |
code_verifier |
絶対に残さない |
state / iss |
残ってよい(秘密ではない) |
リバースプロキシのアクセスログはクエリ文字列を丸ごと記録する既定になっていることが多い。
/callback へのリクエストだけログのフォーマットを分けるか、クエリを落とす。
引っかかりやすいポイント
Section titled “引っかかりやすいポイント”- OAuth 2.0 は認可の仕様。認証が要るなら OIDC を重ねる
- access token が取れたことをログイン成功の根拠にしない
-
response_type=code以外(token、id_token token)は新規採用しない - パスワードグラントは MUST NOT(RFC 9700 §2.4)
- PKCE は public クライアントに MUST。モバイル専用ではない。SPA も対象
-
code_challenge_method=S256を明示する。省略するとplain扱い - S256 が失敗しても
plainにフォールバックしない(RFC 7636 §7.2) -
code_verifierは 43〜128 文字。乱数 32 バイトの base64url がちょうど 43 文字 -
redirect_uriは完全一致。ワイルドカード不可(例外はネイティブの loopback ポートのみ) -
redirect_uriは認可時とトークン交換時で同じ文字列でなければならない -
stateは SHOULD。PKCE が CSRF 対策を代替できるが、両方入れるのが実務 -
issを検証する(RFC 9207)。複数の認可サーバーを扱うなら必須級 - 認可コードは 1 回限り・10 分以内。再送のリトライ処理を書かない
- トークンエンドポイントの
invalid_grantは原因が 4 つ以上ある。サーバー側ログで切り分ける - ネイティブアプリで WebView を使わない(RFC 8252 §8.12)
- 失効エンドポイントは無効なトークンにも 200 を返す(RFC 7009)
- OAuth のメタデータは
/.well-known/oauth-authorization-server、 OIDC は/.well-known/openid-configuration
OAuth 2.0 は認証と認可のどちらの仕様か。
認可。 RFC 6749 の題は “The OAuth 2.0 Authorization Framework”。 「誰がログインしているか」を扱うのは OpenID Connect で、こちらは OAuth の上に載る identity layer。access token をログイン判定に使うのは誤用。
認可コードフローで、利用者のパスワードはどこを流れるか。
利用者のブラウザから認可サーバーへだけ。クライアント(アプリ)は一度も見ない。 これが OAuth が解決した中心的な問題で、パスワードグラント(RFC 9700 で MUST NOT)は この性質を壊す。
フロントチャネルとバックチャネルの違いを説明せよ。
フロントチャネルは利用者のブラウザ経由で、値が URL のクエリ文字列に載る。
履歴・Referer・アクセスログ・プロキシに残るので漏れうる前提。
バックチャネルはクライアントのサーバーと認可サーバーの直接通信で、ブラウザを経由しない。
認可コードフローは code だけをフロントチャネルに流し、
access_token / refresh_token / client_secret / code_verifier はバックチャネルに閉じる。
`code_challenge` と `code_verifier` のどちらがフロントチャネルを通るか。
code_challenge(ハッシュした側)。 code_verifier はクライアントの中に留まり、
トークンエンドポイントへバックチャネルで送る。
code_challenge_method=plain にするとこの前提が崩れ、code_verifier そのものが
URL に載るので PKCE の効果が消える。
PKCE の S256 変換を書け。
code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))RFC 7636 §4.2。base64url はパディング = を外す。
code_verifier は 43〜128 文字の [A-Za-z0-9-._~]。
PKCE はモバイルアプリだけに必要か。
いいえ。 RFC 9700 §2.1.1 は “Public clients MUST use PKCE” と定め、SPA も含む。 さらに “For confidential clients, the use of PKCE is RECOMMENDED” として、 サーバーサイド Web アプリでも推奨している。
`state` と PKCE はそれぞれ何を防ぐか。
state— CSRF。「このコールバックは自分が始めたフローのものか」を照合する- PKCE — 認可コード横取り。「この code を交換しに来たのは認可要求を出した本人か」を証明する
RFC 9700 §2.1.3 は、PKCE をサポートする認可サーバー相手なら PKCE が提供する CSRF 対策に依存してよい(MAY)としている。
`iss` パラメータは何のためにあるか。
mix-up 攻撃の対策(RFC 9207)。複数の認可サーバーを扱うクライアントで、 「どの認可サーバーからの応答か」を取り違えさせる攻撃を防ぐ。
クライアントは iss を issuer 識別子と比較し、不一致なら
“MUST reject the authorization response and MUST NOT proceed”。
認可コードの有効期限として RFC が推奨する上限は。
10 分。RFC 6749 §4.1.2 が “A maximum authorization code lifetime of 10 minutes is RECOMMENDED”。加えて 1 回しか使えない(再使用されたら発行済みトークンを失効させるべき)。
`redirect_uri` にワイルドカードを使ってよいか。
不可。 RFC 9700 §2.1 は
“authorization servers MUST utilize exact string matching except for port numbers in
localhost redirection URIs of native apps”。
ワイルドカードを許すと、オープンリダイレクタやサブドメイン乗っ取りで code を転送できてしまう。
トークンエンドポイントで `invalid_grant` が返る原因を 4 つ挙げよ。
codeが期限切れ、または既に使用済みcodeが別のクライアントに発行されたものredirect_uriが認可リクエスト時と一致しない- PKCE の
code_verifierがcode_challengeと一致しない(RFC 7636 §4.6)
エラーコードが同じなので、クライアント側からは区別できない。 認可サーバーのログで切り分ける。
RFC 9700 が禁止(MUST NOT)したグラントは何か。
resource owner password credentials グラント(§2.4)。 利用者の ID とパスワードをクライアントに直接入力させる方式で、 OAuth が最初に解こうとした問題に逆戻りする。
implicit グラントは SHOULD NOT(§2.1.2)で、こちらは MUST NOT ではない点に注意。
ネイティブアプリで WebView を使ってはいけない理由は。
RFC 8252 §8.12 が “native apps MUST NOT use embedded user-agents to perform authorization requests”。理由は 2 つ。
- アプリ側が入力されたパスワードを読み取れる — OAuth の前提が崩れる
- 利用者が本物の認可サーバーの画面か確認できない — アドレスバーも証明書表示もない
外部ブラウザ(SFSafariViewController / Chrome Custom Tabs)に委ねる。
access token と ID token の違いを 1 行で。
access token は「何をしてよいか」をリソースサーバーに示す値、 ID token は「誰がいつ認証されたか」をクライアントに示す JWT。
宛先が違う。access token をクライアントが解釈してログイン判定に使うのは誤り。
public クライアントの refresh token に RFC 9700 が求めることは。
“Refresh tokens for public clients MUST be sender-constrained or use refresh token rotation”(§2.2.2)。sender-constrained(mTLS / DPoP)にするか、 ローテーションするかのどちらかが必須。
ローテーションでは、古い refresh token の再使用を検知したらファミリー全体を失効させる。
OAuth 2.1 は既に RFC になっているか。
なっていない。 draft-ietf-oauth-v2-1 は 2026 年 9 月時点で -16 リビジョンの
Active Internet-Draft で、Intended RFC status は未確定。
内容は RFC 6749 / 6750 を置き換え、RFC 8252 と RFC 9700 を統合して implicit / パスワードグラントを削除するもの。方向は RFC 9700 と同じなので、 RFC 9700 に従っていれば大きく外れない。
- RFC 6749 — The OAuth 2.0 Authorization Framework
- RFC 9700 — Best Current Practice for OAuth 2.0 Security (BCP 240)
- RFC 7636 — Proof Key for Code Exchange (PKCE)
- RFC 8252 — OAuth 2.0 for Native Apps (BCP 212)
- RFC 9207 — OAuth 2.0 Authorization Server Issuer Identification
- RFC 8414 — OAuth 2.0 Authorization Server Metadata
- RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens
- RFC 7662 — OAuth 2.0 Token Introspection
- RFC 7009 — OAuth 2.0 Token Revocation
- OpenID Connect Core 1.0
- draft-ietf-oauth-v2-1 — The OAuth 2.1 Authorization Framework