コンテンツにスキップ

OAuth 2.0

OAuth 2.0 — RFC 6749

  • OAuth 2.0 は認可の委譲フレームワーク。「利用者のパスワードをアプリに渡さずに、アプリへ限定的なアクセス権を渡す」ための仕組みで、仕様は RFC 6749。発行されたトークンの使い方Bearer トークンの担当。
  • 中核は認可コードフロー。認可の同意はブラウザ経由(フロントチャネル)で取り、トークンの受け取りはサーバー間(バックチャネル)でやる。この分離が OAuth の設計そのもの。
  • RFC 6749 だけを読むと現在の実務と食い違う。 implicit グラントは SHOULD NOT、パスワードグラントは MUST NOT、PKCE は public クライアントに MUST — これらは 2025 年の RFC 9700 が RFC 6749 を更新して定めた。

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_verifiercode_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 との違い

OAuth が置き換えたのは「利用者がサービス A のパスワードをサービス B に教える」という運用だった。

パスワードを丸ごと渡す方式と、OAuth で必要な権限だけを期限付きで委譲する方式の対比

渡すものが「全権の鍵束」から「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 で「同じクライアントが取りに来た」ことを証明する。

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_tokenrefresh_tokenclient_secret
前提 漏れうる 漏れない

認可コードフローが安全なのは、漏れうる経路に「短命で 1 回しか使えず、単体では トークンに換えられない値」しか流さないから。 implicit グラントが廃れたのは、 この設計を捨てて access token そのものをフロントチャネルに載せたため。

実際の値で追う。ドメインは app.example.com(クライアント)と as.example.com(認可サーバー)とする。client_idcode は 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"
# sOiV9ZgIX37UdtxEUzYQz7zKdaDeBwRb8ilMwpFVg4c

printf '%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.1
Host: 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 が項目として並び、許可と拒否を選べる

同意画面は認可サーバーが出す。クライアントは描画にも入力にも関与しない。 scope を粗く切ると、利用者は「全権を渡すか使わないか」の二択になり、同意が形骸化する。

HTTP/1.1 302 Found
Location: https://app.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA
&state=mkHXw-BfKLY
&iss=https%3A%2F%2Fas.example.com
  • code — 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.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&code_verifier=PR-cCntF4taPMakEx75SENhDf26hyVsC59Qxim8LksU

Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JWs6BhdRkqt3:gX1fBat3bV を Base64 したもの。クライアント認証は Basic 認証そのもので、ここでも「Base64 は暗号化ではない」が効く。 public クライアントはこのヘッダを持てないので、代わりに client_id をボディに入れる。

  • redirect_uri — 認可リクエストに含めていたなら REQUIRED。 “Values MUST be identical”。認可時と交換時で違えばエラー
  • code_verifier — RFC 7636 §4.5 で REQUIRED
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” と定める。

{
"access_token": "2YotnFZFEjr1zCsicMWpAA",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
"scope": "photo.read"
}

token_typeBearer なので、ここから先は 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 = 43*128unreserved
unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
  • 43 文字以上 128 文字以下(RFC 7636 §4.1)
  • 使える文字は base64url の安全な文字集合
  • 高エントロピーな暗号論的乱数であること。連番やタイムスタンプは不可

43 という下限は、32 バイトの乱数を base64url にすると 43 文字になることから来ている。 openssl rand 32 を base64url するのがそのまま最小構成になる。

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

plaincode_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 の stateSHOULD であって 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 はアプリ側の状態 (ログイン後の戻り先など)を運ぶ用途でも要るので、併用して損はない。

複数の認可サーバーに対応するクライアント(「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 localhost redirection URIs of native apps

前方一致もワイルドカードも認められないhttps://app.example.com/callback を登録したら、 https://app.example.com/callback?x=1https://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)

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_errortemporarily_unavailable が存在するのは、 リダイレクトで返す以上 HTTP ステータスで表現できないから。

トークンエンドポイント(RFC 6749 §5.2)

Section titled “トークンエンドポイント(RFC 6749 §5.2)”

こちらは HTTP ステータス(400 / 401)と JSON で返す。

error 起きる場面
invalid_request パラメータ欠落・重複
invalid_client クライアント認証に失敗(401WWW-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 の不一致もここに集約される。エラーメッセージが同じなので、 原因の切り分けは認可サーバー側のログでやるしかない。

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 を有効にするのは scopeopenid を入れるだけ。 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)

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"
}

issuerresponse_types_supported が REQUIRED。 code_challenge_methods_supportedS256 があるかを起動時に確認するのが、 RFC 9700 が言う「認可サーバーが PKCE をサポートしていることを確認したクライアント」の実装。

OIDC は別のパス(/.well-known/openid-configuration)を使う。取り違えやすい。

仕様 エンドポイント 用途
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 を入れると、複雑さだけが増える。

  • リソースの所有者と、アクセスしたいアプリが別の主体である(委譲する相手がいる)
  • 認可サーバーとリソースサーバーが TLS で公開されている
  • redirect_uri完全一致で事前登録できる(ワイルドカードを要求しない)
  • クライアントが public か confidential かを判別できている
  • 認可サーバーとクライアントの両方が PKCE の S256 を使える
  • scopeリソース単位で細かく切れる(全権トークンを作らずに済む)
  • トークンの有効期限・失効・ローテーションを運用できる
  • 「誰がログインしたか」が要るなら OIDC を併用する用意がある

ひとつでも欠けたら別の方式を検討する。 特に 1 つ目が欠けている (自社の 1 つの Web アプリに自社のログインを付けたいだけ)なら、 Cookie とセッションのほうが素直で安全。

OAuth 2.0 が成立する 6 つの条件を 1 枚にまとめた復習カード

試験前に見返すならこの 1 枚。 どれが欠けているかで、採用すべき方式が決まる。 コーラルで示した redirect_uri の完全一致は、現場でいちばん破られている条件

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
ケース 成立する理由 一緒にやること
サードパーティ製アプリに自社 API を開放する 委譲の境界が明確。利用者のパスワードを渡さずに済む scope を機能単位で切る。失効の窓口を用意する
SPA / ネイティブアプリから自社 API を呼ぶ フロントチャネルに token を出さずに済む PKCE 必須。refresh token はローテーション
「Google でログイン」を実装する 認可サーバーの運用を委ねられる OIDC の id_token を使う。access token で認証しない
マイクロサービス間の呼び出し 利用者が介在しないので client credentials が使える aud をサービスごとに分ける。有効期限を短く
社内 SaaS を SSO でまとめる 認可サーバーが 1 つで済み、失効も一元化できる メタデータを起動時に取得。iss を検証
ケース できないこと
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.com
CLIENT_ID=s6BhdRkqt3
REDIRECT=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 - <<PY
import urllib.parse
q = 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)
PY
ターミナルウィンドウ
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" | jq

public クライアントは -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 つ。

  1. code_verifierstateサーバー側セッションに置く。Cookie に入れると ブラウザから読めてしまい、PKCE の意味が薄れる
  2. state の比較は timingSafeEqual。文字列 === はタイミング差が出る
  3. redirect_uri認可時と 1 バイトも違えてはいけない。定数にする
  4. access_tokenブラウザへ返さない。サーバー側セッションに保持して、 API 呼び出しはサーバーが代行する(BFF パターン)

「読み取り」と「書き込み」を最低限分ける。 1 つの full_access を作ると、 利用者は同意画面で全権を渡すか使わないかの二択になり、実質的に同意が形骸化する。

photo.read 写真の一覧と取得
photo.write 写真のアップロード
photo.delete 写真の削除

RFC 9700 はリソースサーバーごとに access token を分ける方向を推している。 1 枚のトークンが漏れたときの被害範囲が、そのまま scope の広さになる。

目安 根拠
認可コード 10 分以内(1 分程度が実用的) RFC 6749 §4.1.2 が 10 分を RECOMMENDED
access token 5 分 〜 1 時間 失効が効かない前提で短くする
refresh token 数日 〜 数か月 public クライアントはローテーション必須

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 トークンの記事で扱っている。

扱い
code URL に載るのでアクセスログに残る。 短命にして被害を抑える
access_token / refresh_token 絶対に残さない。ヘッダごとマスクする
client_secret 絶対に残さない
code_verifier 絶対に残さない
state / iss 残ってよい(秘密ではない)

リバースプロキシのアクセスログはクエリ文字列を丸ごと記録する既定になっていることが多い。 /callback へのリクエストだけログのフォーマットを分けるか、クエリを落とす。

  • OAuth 2.0 は認可の仕様。認証が要るなら OIDC を重ねる
  • access token が取れたことをログイン成功の根拠にしない
  • response_type=code 以外(tokenid_token token)は新規採用しない
  • パスワードグラントは MUST NOT(RFC 9700 §2.4)
  • PKCE は public クライアントに MUST。モバイル専用ではない。SPA も対象
  • code_challenge_method=S256明示する。省略すると plain 扱い
  • S256 が失敗しても plain にフォールバックしない(RFC 7636 §7.2)
  • code_verifier43〜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 はそれぞれ何を防ぐか。
  • stateCSRF。「このコールバックは自分が始めたフローのものか」を照合する
  • 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 つ挙げよ。
  1. code が期限切れ、または既に使用済み
  2. code が別のクライアントに発行されたもの
  3. redirect_uri が認可リクエスト時と一致しない
  4. PKCE の code_verifiercode_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 つ。

  1. アプリ側が入力されたパスワードを読み取れる — OAuth の前提が崩れる
  2. 利用者が本物の認可サーバーの画面か確認できない — アドレスバーも証明書表示もない

外部ブラウザ(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 に従っていれば大きく外れない。