# Which invariants prevent consent-scope drift in MCP authorization extensions built on OAuth 2.1 resource-server patterns **Date of Record:** 2026 November 13 **Scope:** Applies to MCP servers acting as OAuth 2.1 resource servers that bind access tokens to tool-level scopes and enforce per-invocation consent, where the authorization server (AS) and the MCP server are distinct trust domains. Does not apply to single-process MCP servers using static API keys, nor to delegated flows where the MCP server itself mints tokens. ## Core Distinction There are two distinct notions of "scope" in an MCP authorization extension, and conflating them is the root of the failure mode: 1. **Token scope** — the set of scopes the AS asserted in the access token (`scope` claim), which the resource server treats as *maximally grantable authority* for the lifetime of that token. 2. **Consent scope** — the set of tools/scopes the *user* approved for *this specific session or invocation*, which the MCP server enforces at call time. The invariant that must hold is a **subset relation at every invocation**: `effective_tool_scope ⊆ consent_scope ⊆ token_scope`. The non-obvious boundary condition is that OAuth 2.1 resource-server patterns guarantee only the second relation (`consent ⊆ token`) at issuance time. Nothing in the resource-server model re-checks `effective ⊆ consent` after token issuance, because the RS is stateless with respect to user consent. An MCP server that trusts token scope as a proxy for consent scope silently widens authority whenever the AS issues a token whose scope exceeds the consent the user actually granted — which happens routinely with refresh, scope-upgrade, and shared-client flows. ## What Was Observed In a deployment where the MCP server validated JWTs locally (JWKS, no introspection), the following sequence produced scope drift without any token forgery or signature failure: - A client obtained a token with `scope="tools:read tools:write"` after the user consented to read-only tooling. The AS had a "remember consent" feature that unioned scopes across prior grants from the same client, so the token carried `tools:write` even though the interactive consent screen for this session showed read-only. - The MCP server's tool dispatcher mapped `tools:write` directly to the `write_file` tool's `required_scope` and admitted the call. The user's *consent* was read-only; the *token* was read-write; the *effective* authority was read-write. - The failure was invisible in logs because every layer behaved correctly against its own contract: the AS asserted a valid scope, the RS enforced the asserted scope, and the tool checked its declared requirement. The broken relation was `effective ⊆ consent`, which no component owned. A second observed variant: token refresh. The AS re-issued a token during refresh and *narrowed* the granted scope (e.g., user revoked write in the AS UI). The MCP server, holding a cached session object keyed by `sub`, continued to authorize against the *cached* consent scope from the original session, admitting write calls for which the current token no longer carried authority. Here the violation was the opposite direction: `consent` (cached) exceeded `token` (current), and the RS authorized on stale state. ## Condition of Validity The subset-chain invariant `effective ⊆ consent ⊆ token` holds at call time only if **all three** of the following are enforced, and the boundary failure occurs precisely when any one is assumed rather than checked: 1. **Consent is a first-class, server-side artifact**, not inferred from token scope. The MCP server must persist the consent decision (which tools, which scopes, which session) independently of the token, and must key it to something the token cannot silently widen — typically a consent record ID bound into the token as a custom claim (`consent_id`) or a `cnf`-style binding, not merely `sub` + `client_id`. 2. **Token scope is treated as a ceiling, never a floor.** The RS computes `effective = requested ∩ consent ∩ token` and rejects if `requested ⊄ consent` *or* `requested ⊄ token`. A request that is within token scope but outside consent is a policy denial, not an authorization success. 3. **Refresh and re-issuance invalidate or re-derive consent state.** If the token's scope changes (narrowed or widened) on refresh, any cached consent record that is broader than the new token must be treated as stale. The safe rule: consent cache entries are invalidated on any token whose `scope` claim differs from the scope observed at consent time. The invariant is *not* valid when the AS and RS share a process (consent and token are trivially consistent), nor when consent is delegated entirely to the AS and the RS performs no per-tool consent at all (in which case there is no `consent` term to violate — the model degrades to plain scope enforcement, which is a different, weaker guarantee). ## Decision Boundary The discriminating question for an operator or agent is: **"Can a valid, unexpired, correctly-signed token cause a tool invocation the user did not approve in the current consent session?"** If the answer is yes, the deployment is in the drift-prone regime regardless of how clean the token validation looks. Concretely: - If consent is derived from `token.scope` alone → **drift-prone**; the AS's consent-unioning or scope-upgrade behavior becomes an authority-widening vector. - If consent is persisted and bound via a claim the AS cannot mutate without re-consent → **bounded**; the remaining risk is stale-cache-on-refresh, handled by rule 3. - If the RS uses token introspection per call and the introspection response includes a consent reference → **bounded**, at the cost of per-call AS round-trips. The boundary is not "JWT vs. introspection." It is whether the consent decision has an identity independent of the token's scope claim. Local JWT validation is fine *provided* the consent binding is carried in a signed claim and re-checked; introspection is insufficient *if* the RS still maps scope directly to tools without a consent record. ## Operational Effect The practical consequence is that tool-scoping correctness in MCP authorization extensions is a **three-way intersection enforced at the resource server**, and the resource server — not the AS — owns the `effective ⊆ consent` check. Deployments that push all consent logic to the AS and treat the RS as a pure scope-enforcer will pass every conformance test and still leak authority under consent-unioning, scope-upgrade, and refresh-narrowing flows. The mitigation is structural: persist consent as a server-side record, bind it into the token, and re-derive the effective set on every invocation from the intersection rather than from any single source. The following reference check encodes the invariant and fails closed on violation. ```python from dataclasses import dataclass from typing import FrozenSet class ConsentScopeDriftError(RuntimeError): """Raised when effective tool authority escapes the consent/token subset chain.""" @dataclass(frozen=True) class InvocationContext: requested_scopes: FrozenSet[str] consent_scopes: FrozenSet[str] # persisted, bound via consent_id claim token_scopes: FrozenSet[str] # scope claim from validated access token consent_id_in_token: str consent_id_in_store: str def authorize_tool_call(ctx: InvocationContext) -> FrozenSet[str]: # Rule 1: consent binding must match; a valid token cannot widen consent. if ctx.consent_id_in_token != ctx.consent_id_in_store: raise ConsentScopeDriftError( f"consent binding mismatch: token={ctx.consent_id_in_token!r} " f"store={ctx.consent_id_in_store!r}" ) # Rule 2: token scope is a ceiling, not a floor. if not ctx.consent_scopes <= ctx.token_scopes: raise ConsentScopeDriftError( f"consent exceeds current token authority: " f"consent-token={sorted(ctx.consent_scopes - ctx.token_scopes)}" ) # Rule 3: requested must be within BOTH consent and token. if not ctx.requested_scopes <= ctx.consent_scopes: raise ConsentScopeDriftError( f"requested outside consent: " f"{sorted(ctx.requested_scopes - ctx.consent_scopes)}" ) if not ctx.requested_scopes <= ctx.token_scopes: raise ConsentScopeDriftError( f"requested outside token: " f"{sorted(ctx.requested_scopes - ctx.token_scopes)}" ) return ctx.requested_scopes # effective = requested, now proven within chain ```