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_scopesbehavior. -
But Cloudflare's server remembered them too. The
scopein 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
200with 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_scopechallenge 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
| 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"
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"
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:
- Parse
WWW-Authenticate. - Determine the required scopes as “the scopes requested last time ∪ the scopes in the challenge.”
- Reauthorize. This is the normal OAuth authorization-code flow.
- 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:readandaccount:read) plusoffline_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;
}
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" }
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
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"
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
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:
- The authorization server preserved previously granted scopes.
- I had included
scope: "user:read account:read"during DCR registration. That was my own confounding factor. - 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.
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.
After approval, the grant was:
Requested: workers-r2.read
Granted: workers-r2.read user:read offline_access account:read
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'sinclude_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
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
}
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
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));
}
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
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": "…" }
}
}
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 403insufficient_scopeinstead of401 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_scopetriggers 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_tokenA valid token for another resource 401 invalid_tokenA malformed string / 39 characters or fewer 401 invalid_tokenOnly 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:
- Who notices that permission is missing? It moves from the application to the server.
-
Is there a way to communicate it? It moves from none (because the app already knows) to
WWW-Authenticate. - 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
-
Cloudflare API MCP server now supports step-up authorization — The announcement that started this investigation. Add
/index.mdto read it as Markdown. - Choose OAuth scopes for Wrangler and the Cloudflare API MCP server — Optional scopes and Edit Permissions on the consent screen.
- MCP Specification 2026-07-28 — Authorization — Step-Up Authorization Flow and Scope Challenge Handling.
- MCP Specification 2025-11-25 — Authorization — The version with three server-side union strategies.
- Issue #2349 — Why responsibility moved to clients; the issue body explains the three arguments.
- typescript-sdk#1582 — Reproduction of the infinite reauthorization loop.
-
cloudflare/workers-oauth-provider —
insufficientScope()and the twoscopes_supportedlists. -
Google — Using OAuth 2.0 for Web Server Applications —
include_granted_scopesand combined authorization. -
RFC 6750 §3.1 — Definition of
insufficient_scope. - RFC 9470 — Step-up Authentication, which is a different concept.
- cloudflare/mcp Issue #302 — The report from §9, filed after publication.
-
cloudflare/mcp PR #305 — The merged fix;
authorize()andrefusalFor()are worth a look.



Top comments (0)