DEV Community

Hirohisa Tanaka
Hirohisa Tanaka

Posted on

MCP Step-Up Authorization vs. OAuth Incremental Authorization: What Actually Changes?

MCP Step-Up Authorization vs. OAuth Incremental Authorization: What Actually Changes?

“There are no zones.” There were three.

TL;DR

When Cloudflare announced step-up authorization for its API MCP Server, I wanted to know how it differed from Google OAuth's Incremental Authorization. I read the specs, wrote my own MCP client, and sent 128 requests to the live service. The additional authorization itself is still ordinary OAuth. The difference is a path for the server to tell the client, “You don't have enough permission.”

  • The client remembers previously granted scopes. Cloudflare's authorization server does not carry over previously granted scopes outside the baseline set. This is the opposite of Google's include_granted_scopes behavior.
  • But Cloudflare's server remembered them too. The scope in a 403 challenge followed the scopes granted to that token. That is the approach MCP removed in the 2026-07-28 specification.
  • The challenge path did not cover every operation. On the same server, some insufficiently authorized operations returned a 403 challenge, while others returned 200 with an empty list.
    • I reported the third finding to Cloudflare, and it was fixed that same day. The follow-up is in §13.

Here is what happened when I called the same APIs before and after granting scopes:

Operation Before granting scopes After granting scopes
List zones 0 items 3 items
List Workers scripts 0 items 12 items
List D1 databases 403 challenge 5 items

Test code: GitHub repository

Let me state the main point upfront.

Step-up Authorization does not introduce a new way to authorize. It introduces a way to communicate that a scope is missing. In a traditional web app, developers know which feature needs which scope. With MCP, an AI chooses tools, so the client may not know in advance. The server has to notice that permission is missing, and it needs a way to tell the client. When that path works, a client can start with minimal permissions. When it doesn't, the AI simply reports that something “doesn't exist.”

This article expands on my lightning talk, “How Is MCP Step-up Authorization Different from OAuth Incremental Authorization?”, with measurements I couldn't fit into the talk and the details of a conclusion I had to retract once. It is for readers who know the basics of OAuth 2.0 and may build MCP servers.


1. The third step in a three-part rollout

On October 19, 2026, Cloudflare posted this in its Changelog:

Grant only the required scopes when you connect, then authorize more on demand as your agent performs a task. When a tool call needs a scope you have not granted, the server returns an insufficient_scope challenge naming it, and your MCP client asks you to approve it. Scopes you already granted are kept.

Start with the minimum, then add permissions when needed. When I read that, I couldn't tell how it differed from include_granted_scopes=true, which I already use in my own projects. That question started this investigation.

The announcement was not an isolated change. It was the third step in a rollout:

Date Event
August 22, 2026 Wrangler and the API MCP Server added optional scopes and “Edit Permissions” on the consent screen
October 1, 2026 workers-oauth-provider v1 made step-up possible with a one-line insufficientScope() call
October 19, 2026 The API MCP Server added step-up authorization

The August Changelog ended with this instruction:

If a command or tool call needs a scope that you declined, reauthorize the client and grant that scope.

At that point, if you declined a permission, you had to reconnect manually. The ability to grant fewer permissions came with extra friction. The October change removed that manual step.

2. Two versus 384

Cloudflare's MCP server publishes two different scopes_supported lists. They have the same name, but different RFCs define them for different purposes.

curl -s https://mcp.cloudflare.com/.well-known/oauth-protected-resource/mcp
curl -s https://mcp.cloudflare.com/.well-known/oauth-authorization-server
Enter fullscreen mode Exit fullscreen mode
Metadata RFC Count Contents
Protected Resource Metadata RFC 9728 2 user:read, account:read
Authorization Server Metadata RFC 8414 384 dns.write, zone.write, workers-r2.read, …

The first list says what is minimally needed to access this resource. The second is the full catalog of scopes this authorization server can issue. The MCP specification describes the first as the minimal set of scopes necessary for basic functionality.

Without authentication, the server returns a 401 naming those two scopes:

HTTP/2 401
www-authenticate: Bearer realm="OAuth",
  resource_metadata="https://mcp.cloudflare.com/.well-known/oauth-protected-resource/mcp",
  scope="user:read account:read"
Enter fullscreen mode Exit fullscreen mode

The gap between 2 and 384 is why this feature exists. Without step-up, the client has to choose from 384 scopes up front. Request too few and things don't work; request too many and you overgrant access. Starting with just two scopes is practical only if you can add more when you discover you need them.

3. Step-up builds on RFC 6750

In the MCP specification, step-up starts with a 403 insufficient_scope response:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
                         scope="files:write",
                         resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
                         error_description="File write permission required for this operation"
Enter fullscreen mode Exit fullscreen mode

MCP did not invent this error code. It was defined in RFC 6750 §3.1 in 2012:

insufficient_scope — The request requires higher privileges than provided by the access token.

What MCP adds is the procedure that turns this response into reauthorization:

  1. Parse WWW-Authenticate.
  2. Determine the required scopes as “the scopes requested last time ∪ the scopes in the challenge.”
  3. Reauthorize. This is the normal OAuth authorization-code flow.
  4. Retry the original request.

Compared with Google OAuth Incremental Authorization, the differences come down to three questions:

Google Incremental Authorization MCP Step-up Authorization
Who notices that more access is needed? The application, before it calls the API The server
How is the missing scope communicated? No challenge; the app already knows WWW-Authenticate: scope="…"
Who remembers previously granted scopes? The authorization server (include_granted_scopes=true) The client
How does reauthorization work? OAuth authorization-code flow The same flow

The third row cannot be resolved by reading the specification alone. The Changelog says “Scopes you already granted are kept,” but leaves out the subject. The client may be taking a union, or the authorization server may be remembering the earlier grant. Both interpretations fit. Determining which one was true in the live service became the center of my test.

Terminology: baseline
In this article, baseline means the minimal scopes named by Protected Resource Metadata (user:read and account:read) plus offline_access. Cloudflare's consent screen presents these three every time, regardless of previous grants.

4. It was not a new feature in the 2026-07-28 spec

The Changelog linked to step-up in MCP 2026-07-28, so I assumed it was new. It wasn't. The “Step-Up Authorization Flow” section was already in the 2025-11-25 specification. It was absent before 2025-06-18.

What changed in 2026-07-28 was who creates the union of scopes:

Specification Who creates the union?
2025-11-25 The server
2026-07-28 The client

The 2025-11-25 version gave servers three choices: Minimum, Recommended, and Extended. All three assumed that the server would include previously granted scopes in the 403 challenge. The 2026-07-28 version removed all three choices.

The reason is in Issue #2349:

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

TypeScript SDK Issue #1582 demonstrates the loop. Imagine a server where initialize requires init, while tools/list requires mcp:tools:read:

sequenceDiagram
    participant C as MCP Client
    participant S as MCP Server
    C->>S: tools/list (token: init)
    S-->>C: 403 scope="mcp:tools:read"
    Note over C: Overwrites _scope; loses init
    C->>S: tools/list (token: mcp:tools:read)
    S-->>C: 200
    C->>S: initialize (token: mcp:tools:read)
    S-->>C: 403 scope="init"
    Note over C: Overwrites _scope again; loses mcp:tools:read
    Note over C,S: The loop continues

If a 403 faithfully returns only the scope required by that operation, the previous permission disappears as soon as the client overwrites its scope list. A union is not just good manners; it prevents this failure mode.

5. I wrote a client without the SDK

I did not use @modelcontextprotocol/client for this test. As soon as that library sees a 403, it calls computeScopeUnion() and automatically unions the scopes before reauthorization:

function computeScopeUnion(...scopes) {
    const seen = new Set();
    for (const scope of scopes) {
        if (!scope) continue;
        for (const token of scope.split(/\s+/)) if (token) seen.add(token);
    }
    return seen.size > 0 ? [...seen].join(" ") : void 0;
}
Enter fullscreen mode Exit fullscreen mode

That is a correct implementation. But it means I cannot test reauthorization without a scope union. It would be impossible to isolate who is preserving the previous scopes. So I wrote a minimal client that manually performs OAuth and MCP requests. Being able to choose the requested scopes each time was essential to this test.

I did not need to pre-register a client. Cloudflare's authorization server supports Dynamic Client Registration, so the script can register itself as a public client. It does not receive a client_secret.

The transport does not throw on HTTP errors. A 400 or 403 is an observation, so the client records and returns the status, headers, and body instead of swallowing them. Every request passes through one hook and is logged as one line of JSON per round trip. Secrets are replaced with fingerprints, not removed entirely:

"access_token": { "_redacted_len": 82, "_sha256_16": "184d68d85f68caab" }
Enter fullscreen mode Exit fullscreen mode

Replacing a value with <redacted> makes it impossible to show whether the same token was sent repeatedly or a new one was issued each time. A fingerprint shows both without disclosing the token. I later used matching fingerprints sent to two resources as evidence that they were the same token.

Two requested scopes, three granted

The consent process has two screens. First, you choose a preset that includes “Minimal.” Then you see a breakdown of the requested permissions. The Changelog's instruction to “choose Minimal on the consent page” refers to the first screen.

The second screen has REQUIRED and ADDITIONAL ACCESS sections. Required permissions cannot be removed. Individual optional permissions can be unchecked through Edit Permissions. This is where the optional-scope change from August appears. I show the actual screen in §7.

I requested two scopes, user:read account:read, but received three:

Requested: user:read account:read
Granted:   user:read offline_access account:read
Enter fullscreen mode Exit fullscreen mode

I briefly wondered why I had an offline_access scope I never requested. The screen explained it: Background Access was listed as REQUIRED. This made a basic lesson matter from the first test: record requested scopes and granted scopes separately.

6. The challenge scope included the baseline

To trigger 403 responses, I tried read-only endpoints systematically. The OpenAPI specification returned by the search tool includes x-api-token-group, whose description says:

API token permissions the endpoint accepts (any one); OAuth scopes have the same names

This lets you derive the mapping between scopes and endpoints from the server itself. For example, GET /zones lists Zone Zone Read.

When I called an operation requiring a scope I did not have, I got this challenge:

HTTP/2 403
WWW-Authenticate: Bearer realm="OAuth", error="insufficient_scope",
  scope="user:read account:read workers-r2.read",
  resource_metadata="https://mcp.cloudflare.com/.well-known/oauth-protected-resource/mcp",
  error_description="This operation needs the workers-r2.read scope"
Enter fullscreen mode Exit fullscreen mode

The scope includes the baseline. This is where Cloudflare's implementation and its own documentation diverged from the MCP specification:

Source What to include in the 403 scope
MCP 2026-07-28 specification Only the scope needed by the operation is sufficient; including previous scopes is not required
workers-oauth-provider documentation Include all scopes, including the baseline
Live service The latter

The error_description explicitly names the missing scope, which is also helpful for a human reading the response.

7. Who remembers? I got this wrong once

This is the central question. I requested only d1.read, deliberately dropping the three scopes I already had:

Requested: d1.read
Granted:   d1.read user:read offline_access account:read
Enter fullscreen mode Exit fullscreen mode

Consent screen after requesting only scope=d1.read. D1 Read and Account Read appear under ADDITIONAL ACCESS, with 4 total permissions.

The three scopes I had not requested were still there. I almost wrote, “The authorization server preserves previous grants.”

That was wrong. There were three possible explanations, and this observation could not distinguish between them:

  1. The authorization server preserved previously granted scopes.
  2. I had included scope: "user:read account:read" during DCR registration. That was my own confounding factor.
  3. The resource simply adds its baseline scopes by default.

I had introduced the second confound myself. So I created a control: a brand-new client with no previous grants, registered with only d1.read, and opened the same consent screen.

Consent screen for a client with no previous grants. It is identical to the first screen.

The screens were identical: four permissions, User Read and Background Access under REQUIRED, and D1 Read and Account Read under ADDITIONAL ACCESS. Account Read appeared even though this client had never been authorized before.

So explanation 3 accounted for it. I retracted my conclusion.

I redesigned the test: start with a non-baseline scope, d1.read, then request only workers-r2.read. The default baseline could no longer explain the result.

D1 Read was absent from the consent screen, even though it had been granted moments earlier.

Consent screen after requesting only scope=workers-r2.read. Developer Platform shows only Workers R2 Storage Read; D1 Read is absent.

After approval, the grant was:

Requested: workers-r2.read
Granted:   workers-r2.read user:read offline_access account:read
Enter fullscreen mode Exit fullscreen mode

d1.read was gone. I verified the behavior rather than relying on the text alone: the D1 list endpoint returned a 403 challenge again. The capability itself had been removed.

Cloudflare's authorization server does not carry over previously granted scopes outside the baseline.
The Changelog's statement that “Scopes you already granted are kept” is true because the client creates a union. That is structurally the reverse of Google's include_granted_scopes, where the authorization server combines the scopes.

8. The server remembered them too—the approach the spec dropped

I changed the scopes in the token and observed the challenge several times:

Scopes held Operation Challenge scope
Baseline only List R2 user:read account:read workers-r2.read
Baseline + workers-r2.read List D1 user:read account:read workers-r2.read d1.read
Baseline + workers-r2.read List KV user:read account:read workers-r2.read workers-kv-storage.read

The challenge was not a fixed baseline. It followed the scopes granted to the token. In other words:

challenge.scope = scopes already granted to token ∪ scopes required by operation
Enter fullscreen mode Exit fullscreen mode

This is the “Recommended approach” from the 2025-11-25 specification—the approach removed in 2026-07-28. Issue #2349 said this was “impossible” for stateless servers because the server needs to know what scopes the token contains to build the union. Cloudflare shares state between its authorization server and resource server, so it does not have that limitation.

One important consequence is that older clients keep working. @modelcontextprotocol/sdk@1.32.1 overwrites _scope in its 403 handler:

if (error === 'insufficient_scope') {
    if (scope) {
        this._scope = scope;   // Overwrites the value; previous scopes are lost
    }
Enter fullscreen mode Exit fullscreen mode

That is exactly the condition for the loop in §4. It still works against Cloudflare because the server builds the union on the client's behalf. The specification puts the responsibility on the client, but this implementation currently does both.

9. “There are no zones”

While testing endpoints systematically, I found operations that did not return a 403:

Operation Required scope Result
R2 / KV / D1 / Queues / Images Each resource's *.read scope 403 + challenge
List zones zone.read 200 + empty list
List Workers scripts workers-scripts.read 200 + empty list
/user/tokens Not in the catalog 403 in the tool result text

For the two middle rows, I could not initially tell whether permissions filtered the results or the account really had zero items. I granted zone.read and workers-scripts.read, then repeated the same calls:

List zones:             0 items → 3 items
List Workers scripts:  0 items → 12 items
Enter fullscreen mode Exit fullscreen mode

The resources existed in the account. It was the same account and client; I changed only the scopes.

These operations fall outside the step-up path. Since they do not return a 403, the client does not learn that it lacks permission. It never gets a chance to ask the user for additional authorization. The AI says, “There are no zones,” and the conversation ends there.

/user/tokens fails differently:

Cloudflare API error: GET /user/tokens returned HTTP 403: 9109: Unauthorized to access requested resource. No OAuth scope covers this endpoint, so OAuth connections cannot use it. Connect with an API token that has one of these permissions instead: API Tokens Write, API Tokens Read.

There is no corresponding OAuth scope in the catalog, so there is no way to step up. This is an honest implementation: the reason is returned as text that both the AI and the user can read.

Within one server, I found three different ways an authorization failure can be represented. This difference matters when you design how to give an agent access. Only operations that return a challenge can recover on their own after starting with minimal permissions.

10. Refresh cannot add scopes

The reason additional authorization has to return to the browser is in RFC 6749 §6: a refresh token cannot be used to broaden its scope. I tested it directly:

Request HTTP Granted scopes
Omit scope (control) 200 Same original seven
Broaden (workers-kv-storage.read added) 200 Same original seven
Narrow (request baseline only) 200 Same original seven

The broader request follows the RFC. The extra scope is silently ignored rather than rejected. I confirmed this behaviorally too: the KV list still returned a 403.

The narrowing result surprised me. RFC 6749 §6 does not prohibit narrowing, and the workers-oauth-provider docs say, “Token and refresh requests can only narrow the grant.” In the live service, the request was ignored and d1.read still worked.

The library source implements narrowing correctly:

let tokenScopes = this.downscope(body.scope, grantData.scope);

private downscope(requestedScope, allowedScopes) {
  if (!requestedScope) return allowedScopes;
  return requestedScopes.filter((scope) => allowedScopes.includes(scope));
}
Enter fullscreen mode Exit fullscreen mode

The docs accurately describe the library. The mismatch is in the hosted service. The challenge header matches insufficientScope() in workers-oauth-provider, but refresh-scope handling and token validation are different. Do not assume that an OSS library and a hosted service with the same name behave identically.

I also found two related details: refreshes are serialized, and overlapping requests return 429 temporarily_unavailable; and the refresh_token rotates on every refresh.

I wrote a conclusion without having measured it

At first, I sent the three refresh cases back to back. The second and later requests failed with 429, so the requests that were supposed to test the main point never ran. Yet the script printed “scope was not broadened.” An empty diff did not mean that was the service's behavior; the request had not reached the relevant code.

I retracted the result and reran the tests with delays. I changed the script to treat 429 as “not measured yet” and retry with exponential backoff. Do not mistake a conclusion printed by a script for an observation.

11. The 2026-07-28 protocol has no initialize

This was the first implementation snag. I sent initialize and got a 400 response:

-32020 the request headers and body disagree: an initialize request
       (legacy handshake) was sent with a modern MCP-Protocol-Version header
-32602 the MCP-Protocol-Version header names protocol revision 2026-07-28,
       but the request is missing the required per-request envelope key(s): _meta
-32020 the body names method tools/list but the required Mcp-Method header is absent
Enter fullscreen mode Exit fullscreen mode

The 2026-07-28 protocol is stateless and has no initialize handshake. Instead, every request carries this in params._meta:

{
  "_meta": {
    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
    "io.modelcontextprotocol/clientCapabilities": {},
    "io.modelcontextprotocol/clientInfo": { "name": "…", "version": "…" }
  }
}
Enter fullscreen mode Exit fullscreen mode

It also repeats the request body information in headers: Mcp-Method: tools/call and Mcp-Name: <tool name>. That lets an intermediary route or rate-limit requests without opening the body—a useful design for a company that operates a CDN. The Claude Code binary does the same thing.

The legacy initialize still works if you omit the MCP-Protocol-Version header. I confirmed both 2025-11-25 and 2026-07-28 return 200 with the same token.

Aside: a 401/403 mix-up
This is not directly related to step-up, so I will keep it short. A randomly generated 43-character string that had never been issued returned 403 insufficient_scope instead of 401 invalid_token. Binary search narrowed the boundary to exactly 40 characters: strings of 40 or more [A-Za-z0-9_-] characters got a 403; 39 or fewer characters, or strings containing ., :, or spaces, got a 401.

Since 403 insufficient_scope triggers step-up in MCP, I wondered whether an expired client might be prompted to request more permissions. It did not. I tested three realistic cases, and all returned 401:

State a real client might have Result
A real, revoked token 401 invalid_token
A valid token for another resource 401 invalid_token
A malformed string / 39 characters or fewer 401 invalid_token

Only a deliberately constructed, never-issued string returned 403. It is an implementation flaw, but it has no practical impact.

I also tested revocation granularity. Revoking an access token leaves the refresh token alive. Only revoking the refresh token results in invalid_grant "Grant not found".

12. So what actually changes?

There are three differences. Everything else is still OAuth:

  1. Who notices that permission is missing? It moves from the application to the server.
  2. Is there a way to communicate it? It moves from none (because the app already knows) to WWW-Authenticate.
  3. Who remembers previously granted scopes? It moves from the authorization server to the client.

Reauthorization itself is the authorization-code flow in both cases. Step-up does not replace OAuth; it is an entry point back into OAuth.

Testing also made it clearer why MCP needs this. In a traditional web app, developers know which scopes each feature needs. With MCP, the AI selects a tool based on the user's request, so the client may not know in advance. With a tool like execute() that can call an arbitrary API internally, the needed permission may not be known until the tool runs. The server is the only component that can notice, so it needs a way to communicate that fact.

Looking at the implementation, the responsibility that the spec assigns to one side was held by both sides in practice: the client builds a union, and the server returns one too. That is a sensible transition strategy. Older clients do not break.

The remaining issue is in §9. When an operation does not return a 403, the AI cannot tell that permission is missing. This is less a Cloudflare-specific flaw than a mismatch between permission-filtered lists and AI agents. APIs have returned empty lists for a long time; a human might guess that their permissions hide the results. AI does not infer that. It reports that nothing exists.

As an MCP server author, I took away one thing: do not silently return an empty list when permissions are missing. Return a 403 challenge, and the client can ask the user. The specification provides the path. Whether an operation uses it is up to the server implementation.

13. Update: I reported it, and it was fixed that day (2026-10-21)

After publishing the article, I reported the §9 issue to cloudflare/mcp as Issue #302, titled “A list filtered down to nothing by permissions never raises a scope challenge.”

Ten minutes later, I opened PR #304. Two minutes after that, Cloudflare opened PR #305.

Time (UTC) Event
14:41 Issue #302
14:51 PR #304 (mine)
14:53 PR #305 (Cloudflare)
22:11 #305 merged

This was not a retrospective rewrite: both PRs were opened almost simultaneously. #305 was the one that merged, with this note:

Fixes #302, building on @tanahiro2010's report and branch.

The merged commit includes my name in Co-authored-by. I expected the report might be accepted and fixed, but I did not expect the fix to build on my branch and credit me as a co-author.

One goal accomplished: becoming a contributor to some Cloudflare repository while still a student. Well, I guess maintainer is next…

What changed

Lists empty because of missing permissions are now treated as refusals. GET /zones, which previously returned 200 with an empty list, now returns 403 and a WWW-Authenticate challenge. The same applies to execute, endpoint tools, and forge tools.

Of the three failure modes in the article's original table, the second now matches the first:

Operation At publication After #305
R2 / KV / D1 403 + challenge Unchanged
List zones / Workers scripts 200 + empty list 403 + challenge
/user/tokens 403 in result text Unchanged

A useful implementation detail

One thing I liked in the implementation is that it does not treat every empty list as suspicious.

authorize() in src/api-permissions.ts returns one of granted, missing, unreachable, direct, or unlisted. refusalFor() in src/api-refusals.ts converts an empty list into a refusal only when the result is missing. It does not challenge a list that contains even one item.

That means accounts that really have zero items are not caught up in this. The challenge applies only when the connection has none of the scopes accepted by that endpoint. If the account really is empty, the user is asked about scopes once and the flow ends.

The refusal message is also careful: it does not claim that Cloudflare returned a 403. It says that the server returned 200 and an empty list, then adds the challenge without misrepresenting the response.

The naming mismatch I mentioned in §6 was fixed too. Aliases now map values such as Zone Zone Read from x-api-token-group to their catalog scope names. Without that mapping, the refusal could incorrectly tell users that no OAuth scope covers the endpoint. That is what GET /zones reported before my issue.

The tests record and classify responses for 549 endpoints. tests/filtered-lists.test.ts adds 292 lines, and the test fails against main before the fix.

Where this article stands

I am keeping §§9 and 12 as a record of what I observed at publication. If you call mcp.cloudflare.com now, the zone list no longer returns an empty array.

The broader conclusion still holds: whether an MCP server silently returns an empty list or a 403 challenge when permission is missing is the server author's decision. The spec provides the path, but the implementation decides whether to connect it. Cloudflare has now connected it.

References

Top comments (0)