Cheat sheets

MCP Security and Authorization

Find and fix token refusals, poisoned tool metadata and injected content. CIMD, PKCE, iss, audience and PVE checks.

  • Concepts and terms
  • Tasks and commands
  • Annotated code
  • Diagrams
  • Comparisons
  • Troubleshooting
  • Questions and tips

Source

Token refusals#

SymptomCauseFix
401 malformedToken does not decodeSend a valid JWT in Authorization
401 iss not allowedIssuer not in allowed_issuersTrust only authorization_servers issuers
401 unknown kidKey missing after one refetchFix the JWKS refresh job
401 bad signatureSignature does not verifyReject. There is no fallback
401 audience mismatchToken is for another resourceRequest it with this resource
401 expiredexp is in the pastGet a new token
403 insufficient_scopeTool needs a larger scopeStep up with combined scopes, new id
Valid tokens fail after a key changeStale JWKS cacheScheduled refresh plus one refetch on a miss

Gateway and content findings#

SymptomCauseFix
metadata_poisoning findingScanner hit in a descriptionBlock and review the full descriptor
rug_pull findingApproved descriptor changedQuarantine until re-approval
unapproved findingNo approved digest for the keyQuarantine until review
shadowing findingTwo servers expose one nameNamespace it, for example notes.search
-32020Mcp-Method or Mcp-Name differs from the bodyReject before routing, RBAC and rate limits
-32021 on a form requestClient declared URL-only elicitationDeclare elicitation form support
Retry refused after confirmrequestState bound to other argumentsAsk for a new confirmation
Agent obeys a web pageNo source tag on retrieved contentTag content and validate each tool call

Validation order on the resource server#

1Decodejwt_decode fails. Return 401 malformed.2Issueriss not in allowed_issuers. Return 401 beforeany JWKS fetch.3Keylook up kid in jwks_cache. On a miss callrefresh_jwks one time.4Signaturejwt_verify fails. Return 401 bad signature.5Audienceaud is not this resource. Return 401 audiencemismatch.6Expiryexp is in the past. Return 401 expired.7Scoperequired scope is absent. Return 403insufficient_scope with the scope.

Authorization code with PKCE#

1Clientfetch protected-resource metadata. Pick anadvertised issuer.2Clientmake a code_verifier and its S256 code_challengewith pkce_pair.3Clientsend client_id, redirect_uri, scope,code_challenge and resource.4Authorization serverreturns code and iss.5Clientcompare iss with the recorded issuer exactly. Onmismatch, drop the response.6Clientexchange the code with code_verifier, the sameredirect URI and resource.7Clientstore the token under (issuer, resource) intokens_by_issuer_resource.

Tasks in code#

TaskCode
Pin a whole descriptordescriptor_digest(tool)
Scan a catalog against pinsscan_catalog(catalog, approved)
Scan one descriptionscan_description(description)
Make a PKCE pairverifier, challenge = pkce_pair()
Step up a scope on 403client.call_with_step_up(tool, server, auth)
Validate a bearer tokenserver.validate(bearer_token, required_scope=<s>)
Re-fetch the key setserver.refresh_jwks()
Run the lesson testspython3 -m unittest discover code/tests -v

Seven attack surfaces#

Metadata poisoning
a description holds instructions unrelated to the tool.
Descriptor rug pull
an approved name, description, schema or annotation changes.
Cross-server shadowing
two backends expose one unqualified name.
Header and body confusion
Mcp-Method or Mcp-Name disagrees with the body.
Capability escalation
a declared feature is read as authorization.
MRTR state tampering
a changed requestState or reused confirmation.
Identity confusion
a familiar display name is read as publisher proof.

PVE: validate before execute#

validator = Validator(
allowed_tools=("search", "send_message", "read_memory"),
sensitive_tools=("send_message",))
call = ToolCall("search", {"query": "<query>"}, intent="research")
contents = [Content("<text>", "user_message")]user text is not scanned
ok, reason = validator.assess(call, contents)allowlist, args, retrieved text
if ok:
executor.run(call)runs only after approval
looks_like_directive("<text>")first marker hit or None
memory_write_guard(MemoryWrite("<text>"))refuses directive-shaped writes

Client enrollment paths#

FactorPre-registeredCIMDDCR
StatusUse first if it existsPreferredDeprecated fallback
Client idFrom the operatorHTTPS URL with a pathMinted by POST /register
AS signalKnown relationshipclient_id_metadata_document_supportedregistration_endpoint
application_typeNot requiredOptionalRequired: native or web
New issuerNew credentialsURL stays portableRegister again
Main riskReuse at a new issuerSSRF on the fetchRegistration spam, token theft

Rules#

  • Send the bearer token in the Authorization header on every request. Never in the URL.
  • Do not add an OAuth flow to a local stdio server.
  • Never forward the MCP token to a downstream API. Get a separate token.
  • Compare iss exactly. No case folding, slash or port changes.
  • The resource server refreshes JWKS. Only the authorization server rotates keys.
  • Fail closed when introspection is down. Network failure is never active: true.
  • Rule of two: one step never combines untrusted input, sensitive data and an external action.

Questions#

Is clientInfo an identity?
No. It is self-reported.
Does a hash pin prove a tool is safe?
No. It proves stability only.
Does PKCE stop a mix-up attack?
No. The client must validate iss.
Do client capabilities grant access?
No. They declare compatibility only.
Is "ignore untrusted instructions" in the prompt enough?
No. That is not enforcement.

Abbreviations#

CIMD
Client ID Metadata Document. Its HTTPS URL is the client id.
DCR
Dynamic Client Registration. RFC 7591, deprecated.
PKCE
verifier and S256 challenge that protect the code.
JWKS
JSON Web Key Set, fetched from jwks_uri, indexed by kid.
RFC 9728
protected-resource metadata. Names the authorization servers.
RFC 8707
resource indicator. Binds the token to one MCP resource.
RFC 9207
iss in the authorization response. Stops mix-up.
PVE
Prompt-Validator-Executor. Cheap validator before the main model.