MCPのStep-up Authorization は

OAuthのIncremental Authorization と何が違うのか?

田中博悠 / tanahiro2010

田中博悠 / tanahiro2010

  • 三田学園高等学校 1年
  • GDG Greater Kwansai
  • 個人開発で Google OAuth を使ってる
  • MCPサーバーも書く

tanahiro2010

発端

2026年10月19日

Cloudflare API MCP Server が

Step-up Authorization に対応

Cloudflare Changelog

最小の権限で繋いで

足りなくなったら、足せる

で、読んでて思ったんですけど

問い

include_granted_scopes=true

Google OAuth

これ、昔からありますよね

何が違うんだ?

仕様を読んで

自分でクライアントを書いて

128回 叩きました

HTTP往復・全部ログに残してあります

前提

Cloudflare の MCP サーバーは

scopes_supported を 2 箇所で公開している

名前は同じ、意味は別

RFC意味
Protected Resource9728最低限いるもの
Authorization Server8414発行しうる全部

数えてみました

2 : 384

認証なしで叩くと

HTTP/2 401
www-authenticate: Bearer realm="OAuth",
  resource_metadata="...",
  scope="user:read account:read"

2個で始められる

足りなくなったら足せる前提があるときだけ

仕様

発火点は

403 insufficient_scope

チャレンジ

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer
  error="insufficient_scope",
  scope="files:write",
  resource_metadata="..."

このエラーコード

MCPの発明じゃない

RFC 6750 §3.1

2012年

OAuth Bearer Token Usage

MCPが足したのは

そこから再認可に繋ぐ手順

Step-Up Authorization Flow

  1. WWW-Authenticate を読む
  2. 前回の Scope ∪ チャレンジの Scope
  3. 再認可する
  4. 元のリクエストを再試行

3 は普通の OAuth 認可コードフロー

ここまでで、違いが3つ見えます

Google と MCP

GoogleMCP
気づくのはアプリサーバー
伝える手段不要WWW-Authenticate
覚えてるのは認可サーバークライアント
再認可認可コードフロー同じ

3行目

これ、読んだだけじゃ決まらない

歴史

その前に、ひとつ

新機能じゃなかった

Step-Up の節が入った版

仕様
2025-06-18 以前記述なし
2025-11-25登場
2026-07-28現行

Cloudflare がリンクしてるのは 2026-07-28

じゃあ 2026-07-28 で何が変わった?

和集合を作るのは

仕様誰が
2025-11-25サーバー
2026-07-28クライアント

責務がひっくり返った

SEP-2350 / 2026-03-28 merged

なぜ?

Issue #2349

This contradicts the RFC,
creates an **impossible requirement
for stateless servers**,
and leads to **infinite
re-authorization loops** in practice.

無限ループ

#1582 の再現手順

403 が「その操作に必要なScope」だけ返す

クライアントが _scope を上書きする

そして

tools/list → 403 → init を失う
initialize → 403 → tools:read を失う
tools/list → 403 → init を失う
       ⋮

検証

公式SDKを使いませんでした

@modelcontextprotocol/client

function computeScopeUnion(...scopes) {
  const seen = new Set()
  for (const scope of scopes) {
    for (const t of scope.split(/\s+/)) seen.add(t)
  }
  return [...seen].join(' ')
}

403 を見たら自動で和集合にする

正しい実装です

でも、これだと

和集合を作らない再認可

が投げられない

自前の最小クライアント

400 も 403 も観測結果なので

秘密は指紋にする

"access_token": {
  "_redacted_len": 82,
  "_sha256_16": "184d68d85f68caab"
}

まず最小Scopeで繋いで

普通に動くことを確認してから

403 を踏みに行く

403

踏めました

チャレンジ全文

HTTP/2 403
WWW-Authenticate: Bearer realm="OAuth",
  error="insufficient_scope",
  scope="user:read account:read workers-r2.read",
  error_description="This operation needs
    the workers-r2.read scope"

scope に注目

baseline が入ってる

ここ、割れてました

403 の scope に何を入れる
MCP 仕様必要なものだけでいい
Cloudflare docsbaseline込みで全部
実機後者

本題

誰が覚えているのか

d1.read だけ要求してみる

持ってた3つは、あえて落とす

実験 D-1

要求 : d1.read

consent

付与された

要求 : d1.read
付与 : d1.read user:read offline_access account:read

残ってる

認可サーバーが保持してる

…と書きかけました

敗因

対照が無い

残った理由、3つ考えられる

  1. 認可サーバーが保持している
  2. DCRの登録scopeに入れた(自分のミス)
  3. baseline を既定で出してるだけ

2 は自分で混ぜ込んだ交絡

過去の付与ゼロのクライアントを作って

同じ画面を出してみる

control

完全に同じ

判定を取り下げました

測り直し

baseline じゃない d1.read を持った状態で

workers-r2.read だけ要求する

これなら既定値では説明がつかない

r2 only

D1 Read が無い

直前に付与したばかりなのに

付与された

要求 : workers-r2.read
付与 : workers-r2.read user:read offline_access account:read

d1.read が消えた

文字列だけじゃ足りないので

実際に叩いてみる

D1一覧 → 403

能力ごと失われている

わかったこと

Cloudflare の認可サーバーは

過去の付与を引き継がない

"Scopes you already granted are kept"

が成立するのは

クライアントが和集合を作ってるから

ところが

チャレンジの中身を、よく見ると

トークンを変えて3回観測

持ってたScopeチャレンジの scope
baseline… workers-r2.read
baseline + r2… workers-r2.read d1.read
baseline + r2… workers-r2.read kv…read

baseline 固定じゃない

トークンの中身に追随してる

つまり

challenge.scope =
    トークンの付与Scope
  ∪ その操作に必要なScope

これ、どこかで見ました

2025-11-25 のやり方

2026-07-28 が削除した方

効果があります

[email protected] の403ハンドラ

if (error === 'insufficient_scope') {
  if (scope) {
    this._scope = scope   // 上書き
  }

さっきのループ条件そのもの

それでも落ちない

サーバーが肩代わりしてるから

ゾーン

総当たりしてて、気づいたんですけど

403 が返らない操作がある

権限が無いのに

GET /zones
→ 200  result: []

0件?

本当に無いのか

権限で消えてるのか

区別できない

zone.read を付けて、もう一回

同じ呼び出し

Scope無し付与後
ゾーン一覧0件3件
Workers一覧0件12件

あった

403 が返らないので

クライアントは「足りない」を知らない

ユーザーに聞きにいく機会も、発生しない

AIは「ゾーンはありません」と答える

同じサーバーに3通り

操作権限が無いと
R2 / KV / D1403 + チャレンジ
zones / workers200 + 空配列
/user/tokens文字列で403

step-up できるのは1行目だけ

refresh

追加認可って、必ずブラウザに戻りますよね

なんで refresh じゃダメなの?

RFC 6749 §6

広げられない

投げてみた

要求HTTP付与
省略(対照)200元のまま
広げる200元のまま
狭める200元のまま

広げられないのは仕様どおり

でも、狭めるのも無視される

docs には「狭められる」と書いてある

workers-oauth-provider

ライブラリのソース

private downscope(requested, allowed) {
  if (!requested) return allowed
  return requested.filter(s => allowed.includes(s))
}

ライブラリは正しい

違うのは hosted の方

まとめ

違いは3つ

  1. 気づくのが誰か → サーバー
  2. 伝える手段があるか → WWW-Authenticate
  3. 覚えてるのが誰か → クライアント

再認可そのものは同じOAuth

Step-up は OAuth の代替じゃない

OAuth への入口

なんでMCPだけこの形?

従来のWebアプリ

開発者がどのScopeが要るか知ってる

MCP

AIがツールを選ぶ

execute() は呼ぶまで分からない

クライアントには事前に判断できない

だから「気づく」をサーバーに移した

そのための経路が、Step-up

そして

経路は、まだ全部つながってない

黙って空を返さない

おまけ

対照に投げたデタラメな文字列が

403 insufficient_scope

一度も発行されてないのに

境界を二分探索した

39文字 → 401 invalid_token
40文字 → 403 insufficient_scope

[A-Za-z0-9_-] のとき

丸い数字が出たら実装定数

失効したトークンでも起きる?

潰しました

revoke 済み401 ✅
別リソースのトークン401 ✅
壊れた文字列401 ✅

全部ハズレ

実クライアントの状態からは到達しない

ありがとうございました

田中博悠 / tanahiro2010

https://blog.tanahiro2010.com

← → 移動 ・ f フルスクリーン ・ o 一覧 ・ p 発表者
記事版を読む →