Step-Up Authorization in MCP: The insufficient_scope Flow
An AI client asks for something it wasn't granted. The MCP spec says respond with 403 insufficient_scope and a scope challenge — and there's a union rule most implementations get wrong.
An agent connects to your MCP server with a token good for files:read. Twenty minutes into a task, it tries to write a file. What should happen?
Most implementations return 403 Forbidden and stop. The agent has no idea whether it hit a permissions wall it could climb or a permanent denial, so it either gives up or retries the same doomed call. The MCP authorization specification has a better answer: tell the client exactly which scope it's missing and let it come back with a bigger token. That's step-up authorization, and there's a union rule buried in it that will silently break your integration if you miss it. NamoID's MCP Authorization is a developer preview, so this post is the spec first and our implementation notes second — including what we haven't shipped yet.
Agents discover what they need at runtime
Traditional OAuth clients know their scopes at build time. You write the app, you know it needs calendar:read, you request it at login, done.
Agents don't work that way. An agent is handed a goal in natural language and figures out the tool calls as it goes. It genuinely cannot know at authorization time whether this session will need to write a file, issue a refund, or only read a list. That leaves two bad options and one good one:
- Request everything up front. The consent screen becomes a wall of permissions the user rubber-stamps without reading. This is how you get an agent holding
refunds:writebecause a prompt three steps back might have needed it. - Request the minimum and fail hard. The agent stalls the moment it needs anything more.
- Request the minimum and escalate on demand. The consent screen stays honest, and the user is asked for
refunds:writeat the moment something actually needs it.
The third is what step-up authorization buys you. It's the difference between "this agent can do anything to your account" and "this agent asked to issue one refund, and here's when."
The 403 that tells the client what to do
When a token is valid but too small, the MCP authorization spec says the server SHOULD respond with 403 Forbidden and a WWW-Authenticate header carrying the missing scope, following RFC 6750 §3.1:
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"Four things worth noticing.
403, not 401. A 401 means "your token is bad" — expired, malformed, wrong audience. A 403 with insufficient_scope means "your token is fine, it just isn't enough." Clients branch on this, and conflating them sends the agent into a pointless re-authentication loop with the same scopes.
The scope parameter is the whole point. Without it the client is guessing. With it, the client knows precisely what to ask for.
resource_metadata is repeated here, not just on the 401, so a client that entered mid-flow can still find the authorization server.
Challenge everything at once. The spec is explicit that servers SHOULD include every scope needed for the current operation in a single challenge. Dribbling out one missing scope, then another on the retry, forces a separate consent round-trip per scope. Three prompts to complete one action is how you train users to stop reading them.
The union rule — the part that bites
Here's the sentence that matters most, and the one I'd bet gets skipped:
Determine required scopes by computing the union of the client's previously requested scope set and the scopes from the current challenge.
Read that again in terms of what breaks if you ignore it.
Your agent authorizes with files:read. It works for a while. It then tries a write and gets insufficient_scope, scope="files:write". The obvious move — request files:write and retry — is wrong. Most authorization servers issue exactly the scopes you ask for, so the new token carries files:write and nothing else. The agent can now write, and has silently lost the ability to read.
What follows is worse than a clean failure. The next read returns another insufficient_scope, the client steps up again for files:read, loses files:write, and you have an agent oscillating between two half-tokens, prompting the user every single time. It looks like a consent bug. It's a set-arithmetic bug.
# Wrong — replaces the scope set
next_scopes = challenge_scopes # {files:write}
# Right — accumulates
next_scopes = previously_requested | challenge_scopes # {files:read, files:write}The spec puts the responsibility for this squarely on the client: "Scope accumulation across operations is a client-side responsibility." Your server can emit a perfectly correct challenge and still watch the integration thrash, because the bug lives on the other side. If you're writing the client, own the union. If you're writing the server, expect clients that don't and consider whether your scope challenge should include the already-granted set defensively.
The flow, end to end
1. Client → Server MCP request with files:read token
2. Server → Client 403 error="insufficient_scope" scope="files:write"
3. Client union: {files:read} ∪ {files:write}
4. Client → AS authorize?scope=files:read files:write
+ resource=https://mcp.example.com
+ PKCE challenge, record expected issuer
5. User approves the *incremental* ask
6. AS → Client new token, both scopes, audience-bound
7. Client → Server retry original requestTwo details in step 4 that aren't optional. The resource parameter is a MUST under RFC 8707 — it names the MCP server the token is for, and a server MUST reject tokens minted for someone else. And the client must record the expected issuer before redirecting so it can validate iss on the way back, per RFC 9207. We covered why that matters in OAuth mix-up attacks.
Gotchas worth designing for
Cap the retries. The spec says retry "no more than a few times" and then treat it as a permanent authorization failure. Without a cap, a server that keeps challenging for a scope the user won't grant becomes an infinite consent loop — a great way to get your agent uninstalled.
Track attempts per resource + operation. Retry limits are meaningless if the counter resets on every call. Key it to the pair, not the session.
Scope hierarchies are the server's job. If files:write implies files:read in your model, the spec requires your server to work that out when deciding whether a token is sufficient. Don't push hierarchy resolution onto clients; they'll each get it slightly differently.
Decide what a machine client does. Clients acting for a user SHOULD attempt step-up. Clients using client_credentials MAY step up or fail immediately — there's no human there to consent, so a step-up that opens a browser nobody is watching is just a hang.
Consistency beats cleverness. Whatever strategy you pick for which scopes appear in a challenge, apply it the same way every time. Clients cache and infer; a server that varies its challenge shape teaches them the wrong lesson.
Where NamoID is today
Being precise about this, because "supports MCP authorization" is doing a lot of work in most vendor copy.
Shipped. Our MCP gateway returns a 401 whose WWW-Authenticate carries a resource_metadata pointer, and serves the corresponding RFC 9728 protected-resource-metadata document, so a client can discover the authorization server without configuration. Resource indicators are validated against RFC 8707 §2 — absolute http(s) URI, no fragment — and every access token is audience-restricted. That last one is a rule in our codebase, not an aspiration: we never mint an audience-less bearer token, because a token without an audience becomes portable across unrelated resources that happen to share a signing key.
Not shipped. We do not yet emit insufficient_scope challenges with a scope parameter. Today an invalid or insufficient token gets error="invalid_token", which is honest but less useful than the spec allows. Step-up challenges are on the developer-preview path, and this post is partly us writing down the design before building it.
If you're implementing the server side now, the ordering that made sense to us: protected resource metadata first (nothing else is discoverable without it), then audience binding, then step-up. The first two are MUSTs. Step-up is a SHOULD that quietly determines whether your consent screens stay honest.
Step-up authorization is a small amount of spec that changes the shape of agent consent: least privilege at the start, escalation at the moment of need, and a user who is asked about refunds:write when a refund is actually happening. Get the union rule right and it's nearly invisible. Get it wrong and it looks like your consent screen is broken.
For the wider flow this sits inside, see how MCP authorization works and securing an MCP server. If you're building an MCP server and want to talk through resource, scope, audience and expiry boundaries before you commit to a design, that conversation is free.