Part 3 of 7 · 7 min read
Scopes
Covers: Scopes; Whose data? The principal, and handles; When your server is itself a client: the confused deputy; Attacks on the flow itself.
Scopes
A scope names a permission, not a person. The example has three, in a small hierarchy: notes:read; notes:write (which implies read); admin:export (which implies both). Each tool declares what it needs and the server checks it before the tool runs. The page requires servers to account for hierarchy when deciding whether a token is enough, which is the last row of the table below.
The design advice on the page, and what the example measured:
| Situation | Measured |
|---|---|
| First call is a read | 1 authorization, token scope notes:read, statuses [401, 200] |
| Then a write | statuses [403, 200], 2 authorizations in all, scope now notes:read notes:write, and still 1 consent screen (the person had already approved this client) |
| A user who may not be an administrator asks for an admin tool | the client stopped after 2 attempts and the token never gained the scope |
An administrator's token (admin:export) calls a read tool |
200: a broader scope covers a narrower one |
Three rules fall out of it.
- Challenge with the minimum. The first
401namesnotes:read, not the catalogue. When the server instead offered all three scopes, an administrator's first call produced a token withadmin:export,notes:readandnotes:write: a stolen token's blast radius three times as wide, and a consent dialog that asks for everything and is more likely to be abandoned. The page lists "publishing every scope" and "wildcard or omnibus scopes" among the common mistakes. - Step up on demand. When a valid token is not enough, answer
403witherror="insufficient_scope"and the scope for this operation, all of it in one challenge (not one missing scope per retry). The client re-authorizes with the union of what it had and what was asked, so it does not lose permissions, and retries a bounded number of times (a few) before treating it as a permanent failure. - Down-scoping is allowed. The authorization server may grant less than was asked. The example's user without administrator rights asked for
admin:exportand receivednotes:readonly. The client has to cope with a token that is narrower than the request.
Interview angle: scopes
"How would you design scopes for an MCP server?" Say: per capability, not per user; minimal on the first challenge, with the rest requested on demand through 403 insufficient_scope and a step-up that takes the union; broader scopes that imply narrower ones; server-side authorization on top (a scope in a token is necessary, not sufficient: the tool still decides whether this user may touch this record); and log each elevation. Mention what goes wrong: an omnibus scope, a catalogue offered up front, and treating a claimed scope as sufficient with no server-side check.
Whose data? The principal, and handles
A valid token with the right scope still does not say which records the caller may see. The example's tools take the caller from ctx.principal, which the authorization layer set from the verified token, never from an argument. Measured: alice and bob each reading note n1 got their own n1 (groceries, salary); a request for a note alice does not have got the message "No note 'n9' for you. Call notes_list" (the same text whether the note exists for someone else or not, so it cannot be used to probe); and passing user=bob was refused as an unexpected argument because the schema forbids extra properties.
The newer revision has no protocol sessions, so a server that needs state across calls gives out an explicit handle (a draft id, a cart id) that returns as an ordinary argument. The page names the attack, state handle hijacking: guess or steal someone's handle and use it. Its rules: possession of a handle is not authentication; make handles random; bind them to the verified user on the server (key the stored state by user and handle, with the user from the token); expire them. Measured: with guessable handles (d1, d2) and no binding, alice's guess of bob's d1 worked; with a random handle bound to the user, it failed.
Keep an audit log of who did what: the user, the client, the method and tool, and whether it was allowed. Never log tokens. In the example, the token appeared in the request log only when a server was configured to accept it in the URL, and in no audit entry.
When your server is itself a client: the confused deputy
A server that wraps a third-party API (a calendar, a drive) is often an OAuth client of that API and an authorization server to its own clients at once. The page calls it an MCP proxy, and describes an attack that needs four ingredients together: the proxy uses one static client id with the third party; it lets clients register themselves; the third party remembers consent in a cookie per client id; and the proxy skips its own consent step.
The attack: the victim signs in once through a legitimate client, and the third party sets a consent cookie for the proxy's client id. An attacker then registers a client whose redirect address is theirs and sends the victim a link. The victim's browser still holds the cookie, so the third party skips its consent screen, and the proxy's authorization code goes to the attacker, who exchanges it for access as the victim.
Measured: with no consent step of its own, the code was sent to the attacker and the victim saw 0 screens (the third party showed none because of the cookie). With per-client consent at the proxy, the victim saw 1 screen saying Helpful Tool and https://evil.example/cb, and, being a person who looks, declined: no code sent. A client whose redirect_uri changed after registration was refused outright.
The page's requirements for the proxy are concrete: keep a registry of approved client ids per user and check it before the third-party flow; a consent page that names the client, shows the scopes and the redirect address, resists clickjacking and CSRF; cookies with the __Host- prefix, Secure, HttpOnly, SameSite=Lax, signed or server-side, bound to the client id; exact string matching of redirect addresses; a single-use, short-lived state that is stored only after consent is approved.
Attacks on the flow itself
A stolen authorization code. A code in a redirect can leak (a log, a malicious app on the same machine). Measured against the authorization server: a stolen code with no verifier and with a guessed verifier were both refused (invalid_grant); the same code twice was refused; a code after 61 seconds was refused (the example's limit is 60); a code redeemed with another redirect address was refused. Take away PKCE (a server that does not require it and a client that does not send a challenge) and the stolen code was exchanged for a token. PKCE makes the code worthless without a secret that never left the client.
An open redirect. The authorization server must send the code only to an address the client registered, matched exactly. Measured with a registered address https://app.example.com/cb and a request for https://app.example.com/cb/../open?to=https://evil.example: prefix matching sent the code there; exact matching refused. "Starts with" is not "equals".
Forged responses and mix-up. A client that talks to many authorization servers can be tricked into sending a code meant for an honest server to a hostile one (a mix-up attack). PKCE alone does not stop it, because the verifier travels to the attacker's token endpoint. The defence is the iss check: the client records the issuer before redirecting and compares what comes back, with plain string comparison and no normalisation (a trailing slash makes a mismatch). The page gives the rule as a table, and the example implements it:
Server promised iss |
iss in the response |
Client does |
|---|---|---|
| yes | present | compare with the recorded issuer |
| yes | absent | reject |
| no | present | compare anyway |
| no | absent | proceed |
Measured: a response naming another issuer was rejected; a missing iss from a server that promised one was rejected; with the checks switched off the client continued and accepted a token. The same for a mismatched state: rejected, and accepted by a client without the check. (The page notes a future revision is expected to make iss mandatory for servers.)
Impersonating a local client. A desktop client uses a localhost redirect. A metadata document proves control of a domain, not which process is listening on a port, so an attacker can present the real client's document, bind a local port and receive the code. The authorization server must show the redirect address's host during consent, should warn on localhost-only clients, and may require attestation. Domain trust policies (allowlists, reputation) are the server's choice.