Authorize clients with OAuth

About 31 minutes to read, with 7 examples in 9 sections.

Edition 2026.11.5 Examples passed Nov 4, 2026 70 of 70 examples pass What changed

Written with AI. Verified by people. How we make books

Anyone can check this book or correct it. Report an error · How to fix one

Labs

Learn this chapter by doing it

Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Opens in about 30 seconds.

Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Included with membership: 20 hours a month with Member, or 60 with Member Plus.

Open in Lab, in a new tab

Checking your hours…

Checking for your Lab…

Every chapter is read aloud for members. Chapter 1 of every book is free for everyone. Become a member, or sign in.

Read by an AI voice, word for word from this edition's text. Code, output and tables are on the page.

Pocket TTS by Kyutai, voice alba, from Alba MacKenna's recordings, CC BY 4.0.Pocket TTS by Kyutai, voice eve, from VCTK corpus speaker p361, CSTR, University of Edinburgh, CC BY 4.0, adapted by Kyutai.

By the end of this chapter you can protect the shop's Model Context Protocol server with OAuth. You publish the metadata that leads a client to the authorization server, identify the support team's host with a Client ID Metadata Document, get tokens with PKCE, accept only tokens issued for the shop's server, catch a mix-up attack through the issuer of each response, and require a scope per tool, with step-up when a refund needs more. Everything holds for the specification at revision 2026-07-28 and the Python SDK, mcp 2.3.0.

This book follows one case study throughout: a small online shop for kitchen and garden goods, which we call the shop, and whose support team answers customer tickets through a host. Meet the shop and its API introduces it. Serve over Streamable HTTP moved the shop's server, shop-support, onto an HTTP address so that the support team's hosts could share it. Its Origin check keeps out web pages and nothing else: any program that reaches the port can read orders and issue refunds (Reject requests from other origins). The shop needs to know which host is calling, for which person, and what that person may do. Support staff may read orders and stock; only a support lead may refund.

The Model Context Protocol (MCP) answers with OAuth 2.1. Authorization is optional in MCP and belongs to HTTP transports: a stdio server takes its credentials from the environment instead (authorization). A protected MCP server is an OAuth resource server, the host's client for it is an OAuth client, and tokens come from an authorization server, which may run beside the MCP server or apart from it. You know this flow from the client side of other APIs. What MCP changes is that a client knows nothing about a server in advance. It starts from a URL, so discovery, the client's identity and the token's audience all have to work with no setup between the two.

Four shared modules in examples/lib/ carry this chapter's setup, so each example shows only its idea:

  • lib.auth_server is the shop's authorization server, on loopback: metadata, the authorization code grant with PKCE, iss on every answer, and JWT access tokens bound to one server. Consent is scripted, standing in for the person at the browser. It is a teaching server for these loopback examples, and you must never deploy it: it signs no one in, and its signing key derives from the book's public seed, so anyone with the book can mint tokens it accepts. A deployment uses an established authorization server that meets this chapter's checks. The Python SDK has an authorization server of its own, but its documentation discourages it for new servers, and its metadata advertises neither iss nor Client ID Metadata Documents (SDK authorization docs).
  • lib.oauth_client is the host's side: the SDK's OAuthClientProvider with in-memory token storage, and a scripted browser that follows the authorization server's redirects and hands the answer back to the SDK.
  • lib.tool_scopes adds the scope check per tool that the SDK doesn't have.
  • lib.protected_shop puts them together: chapter 3's five tools behind OAuth, as this chapter leaves them.

Everything runs on 127.0.0.1 over plain HTTP. That is acceptable for local development only: in production, every authorization server endpoint MUST be served over HTTPS (security considerations).

Find the authorization server from the resource

A client that has only a server's URL must learn from the server where to get a token. The pinned revision does this in two steps. First, MCP servers MUST publish Protected Resource Metadata (RFC 9728), a JSON document that names the resource and lists at least one authorization server (authorization). A request without a token gets 401, and its WWW-Authenticate header points to that document in a resource_metadata parameter. A client that finds no such parameter tries the well-known URI, /.well-known/oauth-protected-resource with the server's path inserted, and then without the path (authorization server discovery). Second, the client fetches the authorization server's own metadata (RFC 8414), which lists its endpoints and what it supports.

Two values must match along the way. The resource in the server's metadata must be identical to the URL the client used, or the client must not use the metadata (RFC 9728, section 3.3). And the issuer in the authorization server's metadata must be identical to the issuer the client built the well-known URL from (RFC 8414, section 3.3). Both checks stop a client from trusting a document that describes some other server.

In the shop, the server states its own URL and the authorization server states its issuer. The SDK does the server's half when you give MCPServer an AuthSettings and a token verifier: issuer_url names the authorization server, resource_server_url is the server's own endpoint URL, and required_scopes lists the scopes every request needs. The shop's server requires orders:read and stock:read, because every ticket needs one or both. refunds:write comes later, and only when a refund needs it. The token verifier is lib.auth_server.JwtVerifier, which checks a token's signature, issuer and expiry. On loopback the port, and so the resource URL, is known only once the socket is open, so the example builds the server in a function of its base URL, and serve_built from lib.auth_server opens the socket first. Then the example does what a client does: it sends tools/list with no token and follows the answer.

discover-the-authorization-server.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · pyjwt 2.15.1 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""Protect the shop's server with OAuth, then follow its 401 to the authorization server, as a client does."""from urllib.parse import urlsplitimport httpx2from mcp.server.auth.settings import AuthSettingsfrom lib import auth_server, http_mcp, shop_toolsdef protected(issuer: str):    def build(base_url: str):                                   # the server must know its own URL: the resource        settings = AuthSettings(issuer_url=issuer, resource_server_url=base_url + "/mcp",                                required_scopes=["orders:read", "stock:read"], validate_token_resource=True)        verifier = auth_server.JwtVerifier(issuer)        return shop_tools.create_server(version="0.8.0", auth=settings, token_verifier=verifier).streamable_http_app()    return buildwith auth_server.serve() as issuer, auth_server.serve_built(protected(issuer.url)) as base_url:    first = http_mcp.post(base_url + "/mcp", "tools/list")                     # no token    print("status:", first.status_code)    challenge = dict(part.split("=", 1) for part in first.headers["www-authenticate"][7:].split(", "))    print("challenge parameters:", ", ".join(challenge))    print("challenge error:", challenge["error"].strip('"'))    metadata_url = challenge["resource_metadata"].strip('"')    print("resource metadata path:", urlsplit(metadata_url).path)    resource = httpx2.get(metadata_url).json()    print("resource is the endpoint's URL:", resource["resource"] == base_url + "/mcp")    print("authorization servers:", len(resource["authorization_servers"]))    print("scopes_supported:", " ".join(resource["scopes_supported"]))    found = resource["authorization_servers"][0]    metadata = httpx2.get(found + "/.well-known/oauth-authorization-server").json()    print("metadata issuer is the URL it came from:", metadata["issuer"] == found)    for key in ("code_challenge_methods_supported", "client_id_metadata_document_supported",                "authorization_response_iss_parameter_supported"):        print(f"{key}:", metadata[key])
Outputpython discover-the-authorization-server.py
status: 401
challenge parameters: error, error_description, resource_metadata
challenge error: invalid_token
resource metadata path: /.well-known/oauth-protected-resource/mcp
resource is the endpoint's URL: True
authorization servers: 1
scopes_supported: orders:read stock:read
metadata issuer is the URL it came from: True
code_challenge_methods_supported: ['S256']
client_id_metadata_document_supported: True
authorization_response_iss_parameter_supported: True

The first request got 401, with a challenge carrying error, error_description, resource_metadata and the error invalid_token. That error is the SDK's choice, and a departure from RFC 6750, which says a server SHOULD NOT include an error code when a request carries no credentials at all (RFC 6750, section 3.1). The resource_metadata URL's path is /.well-known/oauth-protected-resource/mcp: the well-known prefix with the server's path inserted, as RFC 9728 asks. The metadata names the endpoint's own URL as its resource (True), lists 1 authorization server, and gives scopes_supported as orders:read stock:read, which the SDK fills from required_scopes.

The challenge has no scope parameter, because the SDK's 401 never sends one (mcp/server/auth/middleware/bearer_auth.py). So a client falls back to scopes_supported. The specification's order is the challenge's scope first, then every scope in scopes_supported, then no scope at all (authorization). That is why scopes_supported should be the smallest set that works, not every scope the server knows: whatever it lists, every host asks every user for.

The authorization server's metadata passes the issuer check (True) and states three things the rest of this chapter relies on: PKCE methods ['S256'], support for Client ID Metadata Documents (True) and iss on authorization responses (True). The SDK's client makes both matching checks for you. It raises OAuthFlowError when the metadata's issuer differs from the expected one, compared as plain strings (SDK migration guide). Its resource check is laxer than RFC 9728: it accepts metadata whose resource is a same-origin path prefix of the server's URL (mcp/shared/auth_utils.py), and a validate_resource_url callback on OAuthClientProvider replaces the check with your own.

Working rule: Give every protected server an AuthSettings with its endpoint's URL as resource_server_url, exactly as clients will type it, and the smallest required_scopes that every request needs. Check the chain with one request that carries no token: a 401, a resource_metadata URL, metadata whose resource is the endpoint, and authorization server metadata whose issuer is the URL you fetched it from. When a value differs by a trailing slash or a port, fix the configuration rather than relaxing the client.

Identify the client with a metadata document

Find the authorization server from the resource tells the host where to get a token. The authorization server still has to know which client is asking, and a host meets MCP servers it has never seen, so it can't have registered with each one's authorization server in advance. The pinned revision ranks the ways to get a client ID: use pre-registered client information if there is any; otherwise a Client ID Metadata Document, if the authorization server advertises client_id_metadata_document_supported; otherwise Dynamic Client Registration; otherwise ask the user (client registration).

A Client ID Metadata Document makes the client ID a URL. The client publishes a JSON document at an https:// URL with a path, and that URL is its client_id. The document must contain at least client_id, equal to the URL exactly, client_name and redirect_uris (client registration). The authorization server fetches the document when the client first appears, checks the ID and the redirect URI against it, and may cache it by its HTTP headers. It must not follow redirects while fetching, and must not cache an error (metadata document draft -02). A document can't carry a shared secret: client_secret is forbidden, so a client identified this way is public and relies on PKCE, or proves itself with a key pair through private_key_jwt (metadata document draft -02, section 4.1). The MCP specification links draft -00 of that document; we cite draft -02, the later text. A client ID from a document also works with any authorization server, where a pre-registered or registered client ID is bound to the server that issued it, and the pinned revision forbids reusing it with another (client registration).

Dynamic Client Registration is deprecated at the pinned revision and kept as a fallback for authorization servers without metadata document support; its earliest removal is the first revision released on or after 2027-07-28 (deprecated features). Replace dynamic client registration moves a client off it.

The support team's host publishes its document at https://support.fernhill.example/oauth/client.json. The examples have no network, and fernhill.example never resolves, so the shop's authorization server can't fetch it. Rather than give the host an http:// client ID that a loopback server could fetch, lib.auth_server keeps the document in a mapping from client ID URL to document, which stands in for the fetch, and applies to it the checks it would apply to a fetched one. That is pre-registration of the URL, which the draft allows as associating a client ID URL with its metadata "through other means" (metadata document draft -02, section 5). It is a test-only stand-in: a deployed authorization server fetches the document over HTTPS. The example prints the document's key values, runs the server's checks on it and on three broken copies, gives the SDK an http:// client ID, and then connects twice, to an authorization server that supports metadata documents and to one that doesn't.

publish-client-metadata.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · pyjwt 2.15.1 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""The support host's Client ID Metadata Document, the checks an authorization server makes on it, and the SDK'sclient using its URL as the client ID, with and without the authorization server's support."""import asynciofrom mcp.client.auth import OAuthRegistrationErrorfrom lib import auth_server, oauth_client, protected_shopdocument = auth_server.client_document()                      # served at auth_server.CLIENT_IDprint("client_id:", document["client_id"])print("redirect_uris:", ", ".join(document["redirect_uris"]))print("token_endpoint_auth_method:", document["token_endpoint_auth_method"])for label, url, candidate in [        ("as published", auth_server.CLIENT_ID, document),        ("with a secret", auth_server.CLIENT_ID, auth_server.client_document(client_secret="not-a-secret")),        ("another client_id", auth_server.CLIENT_ID, auth_server.client_document(client_id="https://partner.example/")),        ("on http", "http://support.fernhill.example/oauth/client.json", document)]:    print(f"document {label}:", auth_server.document_problem(url, candidate) or "accepted")try:    oauth_client.provider("http://127.0.0.1/mcp", browser=oauth_client.ScriptedBrowser(),                          client_metadata_url="http://localhost:4280/client.json")except ValueError:    print("SDK given an http client ID: ValueError")async def connect(**server_options) -> None:    with auth_server.serve(**server_options) as issuer, protected_shop.serve(issuer.url) as url:        browser = oauth_client.ScriptedBrowser()        try:            async with oauth_client.connect(url, browser=browser) as client:                await client.list_tools()            print("client_id sent:", browser.requests[0]["client_id"])        except* OAuthRegistrationError as group:                 # the SDK fell back to registration            print("without metadata document support:", group.exceptions[0])asyncio.run(connect())asyncio.run(connect(advertise_cimd=False))
Outputpython publish-client-metadata.py
client_id: https://support.fernhill.example/oauth/client.json
redirect_uris: http://localhost:4280/callback
token_endpoint_auth_method: none
document as published: accepted
document with a secret: document carries a shared secret
document another client_id: document client_id differs from its URL
document on http: client_id must be an https URL with a path
SDK given an http client ID: ValueError
client_id sent: https://support.fernhill.example/oauth/client.json
without metadata document support: Registration failed: 404 Not Found

The document names https://support.fernhill.example/oauth/client.json as its own ID, one redirect URI, http://localhost:4280/callback, and none as its token endpoint authentication method: the host holds no secret. The published document is accepted. A copy with a secret fails with "document carries a shared secret", one whose client_id names another site with "document client_id differs from its URL", and a client ID on http with "client_id must be an https URL with a path". The SDK makes the same https check on its side: given an http:// client ID, OAuthClientProvider raises ValueError as soon as it's built (mcp/client/auth/utils.py).

Against the shop's authorization server, the SDK sent https://support.fernhill.example/oauth/client.json as the client ID and made no registration request. Against a server that doesn't advertise support, it fell back to Dynamic Client Registration without a word, and the flow ended with "Registration failed: 404 Not Found", because the shop's authorization server has no registration endpoint. The SDK's documentation calls this fallback silent (OAuth clients). For the host, that means an authorization server configured without metadata document support shows up as a registration error, not as a missing feature.

Working rule: Give every host one Client ID Metadata Document at a stable https:// URL, with only the redirect URIs it uses and no secret, and pass that URL to the SDK as client_metadata_url. Use pre-registered credentials where an authorization server has issued them, since they rank first, and keep Dynamic Client Registration only for authorization servers that support nothing else. In tests on loopback, keep the client ID an https:// URL and associate it with a local copy of the document in the test authorization server, rather than giving the client an http:// ID that no production server accepts.

Get a token with PKCE

With its client ID from Identify the client with a metadata document, the host can ask for a token. MCP uses the authorization code grant. The host sends the user's browser to the authorization server's authorization endpoint, the user signs in and consents, and the browser returns to the host's redirect URI with a code, which the host exchanges for a token at the token endpoint. Three parameters of that exchange carry most of its security.

PKCE binds the code to the client that asked for it. The client makes a random code_verifier, sends its SHA-256 hash as code_challenge with code_challenge_method=S256, and reveals the verifier only in the token request, so someone who intercepts the code doesn't have it. MCP clients MUST use PKCE with S256, and MUST refuse to go on if the authorization server's metadata has no code_challenge_methods_supported (security considerations). The plain method, where the challenge is the verifier itself, is "explicitly forbidden" in OAuth 2.1 since draft-16 (OAuth 2.1 draft-16, section 7.5.2). The MCP specification cites draft-13, which still allowed it (OAuth 2.1 draft-13).

The resource parameter (RFC 8707) names the server the token is for, by the server's canonical URI. MCP clients MUST send it in both the authorization request and the token request, whether or not the authorization server supports it (authorization). And state, which the client checks when the browser returns, ties the answer to the request.

In the shop, the SDK's client does all of this for the host. lib.oauth_client.connect gives it an OAuthClientProvider with the host's metadata document URL and a scripted browser in place of a person. The browser opens the authorization URL, follows the authorization server's redirects, and stops at the first one addressed to the redirect URI. The person's consent is scripted at the authorization server: the signed-in user grants every requested scope that their role allows. The example calls get_order_status, reads what the authorization server logged, and then sends the authorization server three requests the SDK never would.

token-with-pkce.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · jsonschema 4.26.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · pyjwt 2.15.1 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""The SDK's client gets a token for the shop's server: authorization code with PKCE S256 and the resource parameter,then what the authorization server does with a code sent twice, a wrong verifier and the plain method."""import asyncioimport osimport httpx2from lib import auth_server, loopback, oauth_client, protected_shop, shop_apiasync def main() -> None:    with auth_server.serve() as issuer, loopback.serve(shop_api.create_app()) as api_url:        os.environ.update(shop_api.server_env(api_url))                  # the server runs in this process        with protected_shop.serve(issuer.url) as url:            browser, storage = oauth_client.ScriptedBrowser(), oauth_client.MemoryStorage()            async with oauth_client.connect(url, browser=browser, storage=storage) as client:                result = await client.call_tool("get_order_status", {"order_id": "O-1873"})            print("O-1873 status:", result.structured_content["status"])            asked, exchanged = issuer.events                              # /authorize, then /token            print("authorization request:", ", ".join(key for key in asked if key != "endpoint"))            print("code_challenge_method:", asked["code_challenge_method"])            print("challenge is S256 of the verifier:",                  oauth_client.s256(exchanged["code_verifier"]) == asked["code_challenge"])            print("same resource in both requests:", asked["resource"] == exchanged["resource"] == url)            claims = auth_server.claims(storage.tokens.access_token)            for claim, expected in [("aud", url), ("iss", issuer.url)]:            # the server; the issuer                print(f"token {claim} as expected:", claims[claim] == expected)            print("token sub:", claims["sub"])            print("token scope:", claims["scope"])            print("refresh token:", storage.tokens.refresh_token)            again = httpx2.post(issuer.url + "/token", data=exchanged)      # the same code a second time            print("code sent twice:", again.status_code, again.json()["error"])            code = oauth_client.authorize_by_hand(issuer.url, url, challenge=oauth_client.s256("the-real-verifier"))            stolen = {**exchanged, "code": code["code"], "code_verifier": "a-guessed-verifier"}            print("wrong verifier:", httpx2.post(issuer.url + "/token", data=stolen).json()["error"])            plain = oauth_client.authorize_by_hand(issuer.url, url, challenge="the-real-verifier", method="plain")            print("plain method:", plain["error"], "/", plain["error_description"])asyncio.run(main())
Outputpython token-with-pkce.py
O-1873 status: delivered
authorization request: response_type, client_id, redirect_uri, state, code_challenge, code_challenge_method, resource, scope
code_challenge_method: S256
challenge is S256 of the verifier: True
same resource in both requests: True
token aud as expected: True
token iss as expected: True
token sub: staff-0007
token scope: orders:read stock:read
refresh token: None
code sent twice: 400 invalid_grant
wrong verifier: invalid_grant
plain method: invalid_request / PKCE with S256 is required

The call worked, and order O-1873 is delivered. The authorization request carried response_type, client_id, redirect_uri, state, code_challenge, code_challenge_method, resource, scope, with the method S256. Its challenge is the hash of the verifier that the token request revealed (True), and both requests named the same resource, the endpoint's URL (True). The access token is a JWT whose aud is the server's URL (True) and whose iss is the authorization server (True). Its scope is orders:read stock:read, the server's scopes_supported, because the 401 named no scope.

The token's sub, staff-0007, names the member of the support staff the host acts for, not a customer, and a server reads it from the SDK's get_access_token().subject. So a staff token can't by itself replace SHOP_CUSTOMER_ID, which the order completion in Complete arguments for the user read from its environment over stdio. For a support host, the customer comes from the record of the ticket the staff member is working on, which the server looks up and checks, never from a tool argument. Only where customers sign in to a host themselves is sub the customer, the case Keep the server from acting as a confused deputy takes up. There is no refresh token (None): the shop's authorization server issues none, and the specification tells clients not to assume one (authorization).

The three requests the SDK never sends show what PKCE and single-use codes stop. The same code sent a second time got 400 invalid_grant: a code works once. A fresh code sent with a guessed verifier got invalid_grant, so a stolen code alone is worth nothing. A request with plain got "invalid_request / PKCE with S256 is required", because the shop's authorization server accepts only S256.

One check falls to the host. The SDK's client always sends S256, but it never reads code_challenge_methods_supported, so it goes ahead with an authorization server whose metadata leaves the field out (mcp/client/auth/oauth2.py). A host that must meet the specification fetches each authorization server's metadata itself before it connects, and refuses one that doesn't list S256.

Working rule: Let the SDK's OAuthClientProvider run the authorization code flow, and check once, from the authorization server's side, what it sends: S256, state, and a resource equal to the server's URL in both requests. Add the check the SDK skips, and refuse an authorization server whose metadata doesn't list S256. On an authorization server you run, accept only S256, make codes single-use and short-lived, and bind each code to its client, its redirect URI and its resource.

Bind each token to one server

The token from Get a token with PKCE names the shop's server in aud, because the host sent resource. That protects no one unless the server checks it. An access token is a bearer credential: whoever holds it can present it. Suppose the support host also connects to a partner's MCP server that trusts the same authorization server. The partner's server receives the tokens the host gets for it, and if the shop's server accepted them, the partner's operator could replay them at the shop. RFC 8707 says the authorization server SHOULD restrict each token's audience to the requested resource, so that a token "legitimately presented to a resource cannot then be taken by that resource and presented elsewhere" (RFC 8707). The other half belongs to the server: MCP servers MUST validate that a token was issued for them as its audience, and MUST NOT accept or pass on any other token (authorization).

In the Python SDK at 2.3.0, the server's half is off by default. AuthSettings.validate_token_resource decides whether the server refuses a token whose AccessToken.resource isn't resource_server_url. Left unset, it warns with MCPDeprecationWarning and behaves as False, and version 3.0 makes True the default (mcp/server/auth/settings.py). The SDK compares the two as URLs, so the case of the scheme and host, a default port or a trailing slash makes no difference (mcp/server/auth/middleware/bearer_auth.py), and it compares whatever your token verifier puts in AccessToken.resource. The other way is a verifier that checks aud itself.

The example signs two tokens with the key of the shop's authorization server, one for the shop's server and one for the partner's, https://partner.example/mcp. It presents each to the shop's server in three configurations: the SDK's default, validate_token_resource=True, and a verifier that passes the shop's URL to the JWT library as the expected audience.

wrong-audience-rejected.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · pyjwt 2.15.1 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""A token issued for another server, presented to the shop's server: accepted by the SDK's default, refused once theserver checks the audience, either through AuthSettings or in its own token verifier."""import warningsfrom mcp.server.auth.settings import AuthSettingsfrom lib import auth_server, http_mcp, shop_toolsPARTNER = "https://partner.example/mcp"                  # another MCP server, with the same authorization serverSCOPES = ["orders:read", "stock:read"]def shop(issuer: str, validate_token_resource: bool | None, verifier_checks_aud: bool):    def build(base_url: str):        resource = base_url + "/mcp"        verifier = auth_server.JwtVerifier(issuer, audience=resource if verifier_checks_aud else None)        settings = AuthSettings(issuer_url=issuer, resource_server_url=resource, required_scopes=SCOPES,                                validate_token_resource=validate_token_resource)        return shop_tools.create_server(auth=settings, token_verifier=verifier).streamable_http_app()    return buildwith auth_server.serve() as issuer:    for label, validate, in_verifier in [("SDK default", None, False), ("validate_token_resource=True", True, False),                                         ("verifier checks aud", False, True)]:        with warnings.catch_warnings(record=True) as caught:            warnings.simplefilter("always")            with auth_server.serve_built(shop(issuer.url, validate, in_verifier)) as base_url:                url = base_url + "/mcp"                own = auth_server.mint(issuer.url, url, SCOPES)                foreign = auth_server.mint(issuer.url, PARTNER, SCOPES)                for name, token in [("own token", own), ("partner's token", foreign)]:                    response = http_mcp.post(url, "tools/list", headers={"Authorization": f"Bearer {token}"})                    print(f"{label}, {name}:", response.status_code)        print(f"{label}, warnings:", ", ".join(sorted({w.category.__name__ for w in caught})) or "none")
Outputpython wrong-audience-rejected.py
SDK default, own token: 200
SDK default, partner's token: 200
SDK default, warnings: MCPDeprecationWarning
validate_token_resource=True, own token: 200
validate_token_resource=True, partner's token: 401
validate_token_resource=True, warnings: none
verifier checks aud, own token: 200
verifier checks aud, partner's token: 401
verifier checks aud, warnings: none

With the SDK's default, the shop's server answered the partner's token with 200: it listed its tools for a token issued to someone else. The only sign was a warning at startup, MCPDeprecationWarning. With validate_token_resource=True, the partner's token got 401, and with the audience check in the verifier it got 401. The shop's own token got 200 in all three configurations, and neither fix warned. Both fixes refuse with 401 and invalid_token, the answer the specification gives for a token that isn't valid for the server.

We recommend validate_token_resource=True, set in lib.protected_shop. It states the decision beside the server's other authorization settings, and it survives a change of token verifier. Check aud in the verifier as well when the verifier decodes the token anyway, since the second check costs nothing.

Working rule: Set validate_token_resource=True on every protected server, and make the token verifier fill AccessToken.resource from the token's aud. Treat an MCPDeprecationWarning from AuthSettings as a failed deployment, not as noise. Test the check with a token for another resource from the same authorization server, which must get 401. A server that calls another service on a client's behalf gets its own token for that service and never passes the client's token on (Never pass a client's token through).

Validate the issuer of each response

Binding the audience, as Bind each token to one server does, keeps a token from working at the wrong server. A mix-up attack strikes earlier, at the authorization code. A host that talks to several authorization servers sends each code to whichever server it believes answered. In a mix-up, an attacker's authorization server gets the host to start a flow with it, then sends the user's browser on to an honest authorization server. The honest server's code arrives at the host's redirect URI, and the host, still believing it is talking to the attacker, sends the code to the attacker's token endpoint. PKCE alone doesn't stop this, "because the client transmits the code_verifier to the attacker's token endpoint" (security best practices). Nor does resource, which the attacker rewrites in the request it forwards.

The defence is the iss parameter of RFC 9207: the authorization server names itself in its redirect, and the client compares that name with the server it sent the user to. The pinned revision introduced this check (changelog). Before redirecting, the client records the issuer from the authorization server's validated metadata, and it compares iss with that record by exact string comparison, with no normalisation of case, ports or slashes (authorization).

The rules are conditional. Authorization servers SHOULD include iss, and one that does MUST advertise authorization_response_iss_parameter_supported. A client MUST compare an iss that is present, whatever the metadata says, and MUST reject a response without iss only when the server advertised support; otherwise it proceeds. On a mismatch, the client must not act on or display any error in the response. The specification expects a later revision to make iss a MUST for authorization servers (authorization). Comparing a present iss that wasn't advertised uses the latitude RFC 9207 leaves to local policy; its default is that clients SHOULD discard such a response (RFC 9207, section 2.4).

In the shop's case, the attacker is the partner. The example sets up the attack with MixUp from lib.auth_server. The partner's MCP server names the attacker's authorization server, which copies the metadata of the shop's authorization server without iss support. Its authorization endpoint forwards the browser to the shop's authorization server with the host's own request, rewritten to ask for the shop's server and every scope. The host's client ID is a metadata document URL, which works with any authorization server, so the shop's authorization server sees a request from the real support host. The example runs the attack twice: against a shop authorization server that sends iss, and against one that doesn't.

mix-up-attack-blocked.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · pyjwt 2.15.1 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""A mix-up attack on the support host: the partner's server names an attacker's authorization server, which sends theuser on to the shop's. The `iss` in the shop's answer gives it away; without `iss`, the attacker gets a token."""import asynciofrom mcp.client.auth import OAuthFlowError, OAuthTokenErrorfrom lib import auth_server, oauth_client, protected_shopasync def attempt(label: str, **shop_options) -> None:    with auth_server.serve(**shop_options) as shop_issuer, protected_shop.serve(shop_issuer.url) as shop_url:        with auth_server.serve_mix_up(shop_issuer.url, target=shop_url) as attacker, \                protected_shop.serve(attacker.url) as partner_url:          # the partner's server names the attacker            browser = oauth_client.ScriptedBrowser()            try:                async with oauth_client.connect(partner_url, browser=browser) as client:                    await client.list_tools()            except* (OAuthFlowError, OAuthTokenError) as group:                error = group.exceptions[0]                print(f"{label}, host stopped:", type(error).__name__, str(error).split(":")[0])            print(f"{label}, user consented at the shop's server:", "code" in browser.arrived)            print(f"{label}, iss in the answer:", "shop's" if browser.arrived.get("iss") == shop_issuer.url else "none")            print(f"{label}, codes the attacker received:", len(attacker.stolen))            for loot in attacker.stolen:                claims = auth_server.claims(loot["access_token"])                print(f"{label}, attacker's token is for the shop's server:", claims["aud"] == shop_url)                print(f"{label}, attacker's token scope:", claims["scope"])asyncio.run(attempt("shop sends iss"))asyncio.run(attempt("shop omits iss", send_iss=False, advertise_iss=False))
Outputpython mix-up-attack-blocked.py
shop sends iss, host stopped: OAuthFlowError Authorization response iss mismatch
shop sends iss, user consented at the shop's server: True
shop sends iss, iss in the answer: shop's
shop sends iss, codes the attacker received: 0
shop omits iss, host stopped: OAuthTokenError Token exchange failed (400)
shop omits iss, user consented at the shop's server: True
shop omits iss, iss in the answer: none
shop omits iss, codes the attacker received: 1
shop omits iss, attacker's token is for the shop's server: True
shop omits iss, attacker's token scope: orders:read stock:read refunds:write

In both runs the user consented at the shop's authorization server (True), because the request in front of them came from the real support host. When the shop sends iss, the answer carried the shop's issuer, the SDK stopped with OAuthFlowError Authorization response iss mismatch, and the attacker received 0 codes. When the shop omits iss, the answer carried none, and the SDK went on, as the specification says it should for a server that never advertised iss. The attacker received 1 code with its verifier, redeemed it at the shop's authorization server, and holds a token for the shop's server (True) with the scope orders:read stock:read refunds:write, refunds included. The host saw only OAuthTokenError Token exchange failed (400).

So the check protects a host only when the honest server sends iss; the security best practices say it "provides no protection against an honest server that does not" (security best practices). The SDK follows the specification's table exactly (validate_authorization_response_iss in mcp/client/auth/utils.py), so whether the shop's tokens can be stolen this way depends on the shop's authorization server, not on the host. The other defence RFC 9700 describes, a distinct redirect URI for each authorization server, doesn't help this host: it "cannot be used in scenarios where clients only register once for the use of many different issuers", and should be used only when nothing else is available (RFC 9700, section 4.4.2.2). A client identified by a metadata document is that case, with one client_id and one list of redirect URIs for every authorization server, so the honest server accepts whichever listed URI the attacker forwards. What a host can do without the server's help is limit which authorization servers it uses at all: an attacker's server that isn't on the host's allow-list never starts a flow.

Working rule: Send iss from every authorization server you run, and advertise it with authorization_response_iss_parameter_supported: true, so that clients reject an answer without it. In a host, keep the SDK's check, add no normalisation around it, and treat a mismatch as an attack: show the user none of that response's error text. When a host must use an authorization server that doesn't send iss, keep the host to an allow-list of authorization servers, and refuse or warn the user about any server not on it. Don't count on a separate redirect URI per server when the host is identified by a metadata document.

Scope tools by what the token allows

With a token checked as Bind each token to one server and Validate the issuer of each response describe, the shop's server knows who is calling. It still has to decide what each caller may do. The token lists the scopes the user granted, and the shop has three: orders:read for finding, reading and quoting orders, stock:read for checking stock, and refunds:write for issuing a refund, which only support leads may grant.

The SDK checks scopes per endpoint, not per tool: AuthSettings.required_scopes applies to every request, and the SDK's own 403 names no scope (mcp/server/auth/middleware/bearer_auth.py). The specification asks for a challenge a client can act on: 403 with WWW-Authenticate: Bearer error="insufficient_scope", the scope the operation needs and resource_metadata, with "all scopes required for the current operation in a single challenge" (authorization). An HTTP middleware can send that. At the pinned revision each tools/call names its tool in the Mcp-Name header, but servers MUST decode its =?base64?...?= form before comparing it with the body (Streamable HTTP), so a check on the raw header lets an encoded issue_refund through. And a client at revision 2025-11-25, which the SDK's server still accepts on the same endpoint, sends no Mcp-Name at all.

So lib.tool_scopes.require decides on what the server will execute, with the credential the SDK will use. It checks the tool in the parsed body's params.name. Mcp-Name, decoded as the SDK decodes it, must match, or the request gets -32020, HeaderMismatch, and a body that isn't one JSON object, or repeats a key, is refused. The SDK authenticates with the first Authorization header (mcp/server/auth/middleware/bearer_auth.py), so the middleware refuses two of them with 400 invalid_request, and reads the token through the SDK's own BearerAuthBackend, answering 401 to a tool call whose token doesn't verify. It denies by default: a tool missing from the map is refused, methods that name nothing, such as tools/list, need only the endpoint's scopes, and any other method, such as prompts/get, resources/read or tasks/get, gets 403. lib.protected_shop maps all five tools, and lib.tool_scopes.hide_unscoped can hide the tools a token can't call (Keep the tool list small). The example signs a token with orders:read and stock:read, lists the tools with hiding off and on, then sends hand-written calls.

refund-needs-a-scope.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · pyjwt 2.15.1 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""Scopes per tool: the shop's server refuses a refund without `refunds:write` with a 403 challenge, decided on theparsed JSON-RPC body however `Mcp-Name` is spelled or left out and with the one token the SDK reads, refusesmethods no scope maps, and can hide the tools a token can't call."""import jsonimport osfrom lib import auth_server, http_mcp, loopback, protected_shop, shop_apiREFUND = {"name": "issue_refund", "arguments": {"quote_id": "Q-00000000aa"}}IN_BASE64 = "=?base64?aXNzdWVfcmVmdW5k?="                                   # "issue_refund", as the spec encodes itdef bearer(token: str, **headers: str) -> dict[str, str]:    return {"Authorization": f"Bearer {token}", **headers}with auth_server.serve() as issuer, loopback.serve(shop_api.create_app()) as api_url:    os.environ.update(shop_api.server_env(api_url))                       # the server runs in this process    for hide in (False, True):        with protected_shop.serve(issuer.url, hide_tools=hide) as url:            token = auth_server.mint(issuer.url, url, ["orders:read", "stock:read"])            listed = http_mcp.post(url, "tools/list", headers=bearer(token)).json()["result"]            print(f"hide_tools={hide}, tools listed:", ", ".join(tool["name"] for tool in listed["tools"]))    print("tools/list cacheScope:", listed["cacheScope"])    with protected_shop.serve(issuer.url) as url:        token = auth_server.mint(issuer.url, url, ["orders:read", "stock:read"])        status = http_mcp.post(url, "tools/call", {"name": "get_order_status", "arguments": {"order_id": "O-1873"}},                               headers=bearer(token))        print("get_order_status:", status.status_code)        refused = http_mcp.post(url, "tools/call", REFUND, headers=bearer(token))        print("issue_refund:", refused.status_code)        print("challenge:", refused.headers["www-authenticate"].split(", resource_metadata")[0])        print("challenge has resource_metadata:", "resource_metadata=" in refused.headers["www-authenticate"])        lying = http_mcp.post(url, "tools/call", REFUND, headers=bearer(token, **{"Mcp-Name": "get_order_status"}))        print("issue_refund named get_order_status in Mcp-Name:", lying.status_code, lying.json()["error"]["code"])        encoded = http_mcp.post(url, "tools/call", REFUND, headers=bearer(token, **{"Mcp-Name": IN_BASE64}))        print("issue_refund with Mcp-Name in base64:", encoded.status_code)        print("issue_refund at 2025-11-25:", protected_shop.call_at_2025_11_25(url, token, REFUND).status_code)        twice = protected_shop.call_with_headers(url, REFUND, [("Authorization", f"Bearer {token}"),                                                               ("Authorization", "Bearer x")])    # read-only, then junk        print("issue_refund with two Authorization headers:", twice.status_code, twice.json()["error"])        unmapped = http_mcp.post(url, "prompts/get", {"name": "review_refund"}, headers=bearer(token))        print("prompts/get, which no scope maps:", unmapped.status_code)        lead = auth_server.mint(issuer.url, url, ["orders:read", "stock:read", "refunds:write"])        allowed = http_mcp.post(url, "tools/call", REFUND, headers=bearer(lead))        print("issue_refund with a lead token:", allowed.status_code, json.loads(allowed.text)["result"]["isError"])
Outputpython refund-needs-a-scope.py
hide_tools=False, tools listed: find_orders, get_order_status, quote_refund, issue_refund, check_stock
hide_tools=True, tools listed: find_orders, get_order_status, quote_refund, check_stock
tools/list cacheScope: private
get_order_status: 200
issue_refund: 403
challenge: Bearer error="insufficient_scope", scope="refunds:write"
challenge has resource_metadata: True
issue_refund named get_order_status in Mcp-Name: 400 -32020
issue_refund with Mcp-Name in base64: 403
issue_refund at 2025-11-25: 403
issue_refund with two Authorization headers: 400 invalid_request
prompts/get, which no scope maps: 403
issue_refund with a lead token: 200 True

With hiding off, the token sees find_orders, get_order_status, quote_refund, issue_refund, check_stock; with hiding on, find_orders, get_order_status, quote_refund, check_stock. Either way the list's cache scope is private, the SDK's default, which suits a list that depends on the token (Cache list results). get_order_status got 200. issue_refund got 403, with the challenge Bearer error="insufficient_scope", scope="refunds:write" and a resource_metadata URL (True). A refund whose Mcp-Name claimed get_order_status got 400 -32020. Two requests that pass a check on the header alone were refused for scope: the refund with Mcp-Name in Base64 got 403, and the refund from a client at 2025-11-25 got 403. A refund with the read-only token and then a junk second Authorization header got 400 invalid_request; a check that read the last header would have let the SDK refund on the read-only token. An unmapped prompts/get got 403. With refunds:write, the call reached the tool, which answered 200 True: the status, then the result's isError, true because the example's quote ID doesn't exist.

Working rule: Map every tool to its scopes, and authorize on the parsed request with exactly the credential the SDK authenticates, never on a header alone: refuse two Authorization headers, and any method your map doesn't cover. Treat Mcp-Name as a hint that must match the body once decoded, and refuse any body you can't parse exactly as the server does. Answer a missing scope with a 403 insufficient_scope challenge that names all of the tool's scopes, and keep required_scopes to what every request needs.

Ask for more scope when a call needs it

The 403 in Scope tools by what the token allows is half of step-up; the client does the other half. On insufficient_scope, the client authorizes again with the union of the scopes it asked for before and the scopes in the challenge, retries, and limits how many times it tries (authorization). The union rule is in the pinned revision's text and not in revision 2025-11-25's (2025-11-25 authorization). Without it, a client that asked for refunds:write alone would lose orders:read, and its next order lookup would start the cycle again. The SDK's client steps up on 403 and unions the scopes (mcp/client/auth/oauth2.py).

In the shop, a support lead and a member of the support staff without the lead role each refund order O-1004 through the SDK's client. The refund, €24.98, is within the policy limit, so no confirmation is involved. The scripted consent lets the lead grant all three scopes and the other user only the two read scopes.

step-up-for-refunds.py Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · jsonschema 4.26.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · pyjwt 2.15.1 · starlette 1.10.0 · uvicorn 0.54.0 Passing
"""Step-up: the SDK's client meets the refund's 403, asks the user again for the union of its scopes and`refunds:write`, and retries. A user who may not grant `refunds:write` gets a token without it, and the refund fails."""import asyncioimport osfrom lib import auth_server, loopback, oauth_client, protected_shop, shop_apiasync def refund(label: str, user_scopes: tuple[str, ...]) -> None:    with auth_server.serve(user_scopes=user_scopes) as issuer, protected_shop.serve(issuer.url) as url:        browser, storage = oauth_client.ScriptedBrowser(), oauth_client.MemoryStorage()        async with oauth_client.connect(url, browser=browser, storage=storage) as client:            quote = await client.call_tool("quote_refund", {"order_id": "O-1004", "reason": "arrived cracked"})            print(f"{label}, quote amount:", quote.structured_content["amount"])            print(f"{label}, quote within limit:", quote.structured_content["within_limit"])            try:                result = await client.call_tool("issue_refund", {"quote_id": quote.structured_content["quote_id"]})                print(f"{label}, refund status:", result.structured_content["status"])            except Exception as error:                print(f"{label}, refund failed:", type(error).__name__, error)        for number, asked in enumerate(browser.requests, start=1):            print(f"{label}, authorization {number} asked for:", asked["scope"])        print(f"{label}, token scope:", auth_server.claims(storage.tokens.access_token)["scope"])async def main() -> None:    with loopback.serve(shop_api.create_app()) as api_url:        os.environ.update(shop_api.server_env(api_url))                    # the server runs in this process        await refund("lead", auth_server.SCOPES)        await refund("non-lead", ("orders:read", "stock:read"))asyncio.run(main())
Outputpython step-up-for-refunds.py
lead, quote amount: 24.98
lead, quote within limit: True
lead, refund status: requested
lead, authorization 1 asked for: orders:read stock:read
lead, authorization 2 asked for: orders:read stock:read refunds:write
lead, token scope: orders:read stock:read refunds:write
non-lead, quote amount: 24.98
non-lead, quote within limit: True
non-lead, refund failed: MCPError Server returned an error response
non-lead, authorization 1 asked for: orders:read stock:read
non-lead, authorization 2 asked for: orders:read stock:read refunds:write
non-lead, token scope: orders:read stock:read

For the lead, the first authorization asked for orders:read stock:read. The refund's 403 started a second, which asked for orders:read stock:read refunds:write, the union, although the challenge named only refunds:write. The retry went through, and the refund's status is requested. For the other user, the second authorization asked for the same union, the user could grant only orders:read stock:read, and the retried call failed with MCPError Server returned an error response. The SDK stepped up once and stopped, so there was no loop. But the host got a generic error, not "this needs a lead".

Working rule: Let the SDK step up, and keep its union of scopes. List every tool a caller could gain by step-up, since a hidden tool never gets the 403 that starts it. In the host, compare the scope the token response granted with the scope the challenge asked for, and when they differ, treat it as a decision, not a fault: tell the user which scope the call needs and pass the ticket to someone who holds it. Never let an authorization server grant by step-up what a user's role doesn't allow; the client only asks, and the authorization server decides.

What goes wrong in production

The shop's server, its authorization server and its host all work on loopback. In production they meet other people's authorization servers, gateways and clients, and each of these failures has a sign you can watch for.

An authorization server that doesn't send iss. It is a SHOULD at the pinned revision, so some authorization servers leave it out, and the SDK then proceeds without any defence against mix-up (Validate the issuer of each response). The sign is metadata without authorization_response_iss_parameter_supported. Check the metadata of each authorization server your host meets, and keep the host to an allow-list of authorization servers while any of them leaves iss out.

The silent fallback to registration. When an authorization server doesn't advertise Client ID Metadata Document support, the SDK registers dynamically without saying so (OAuth clients). The sign is registration errors, or a growing list of registered clients at the authorization server. Log which kind of client ID each connection used.

The audience check is off. A server without validate_token_resource accepts tokens issued for other servers, and the only sign is MCPDeprecationWarning at startup (Bind each token to one server). Make warnings from AuthSettings fail the deployment.

An issuer with a trailing slash. The issuer comparison is by exact string, so https://auth.fernhill.example and https://auth.fernhill.example/ are different issuers, and the SDK raises "Authorization server metadata issuer mismatch". The SDK's metadata models keep a URL without a path as it was sent, but an AnyHttpUrl object built in Python still gains the slash, so pass URLs to the SDK as strings (SDK migration guide). The sign is OAuthFlowError on the first connection to a server, from every host.

No PKCE check in the host. The SDK's client never reads code_challenge_methods_supported (Get a token with PKCE). Nothing fails, so the only sign is an audit: fetch each authorization server's metadata and look for S256.

A localhost redirect taken over. A metadata document proves which domain a client belongs to, not which program listens on a localhost redirect URI, and any local program can bind the port first. The specification says that metadata documents "cannot prevent localhost URL impersonation by themselves", and asks authorization servers to show the redirect's host name to the user and warn about redirects to localhost only (security considerations). The sign is a consent screen that names the real host for a request no one at that machine made.

An authorization server used to probe the network. A server that fetches any client ID URL can be pointed at internal addresses. The draft says authorization servers MUST NOT fetch from special-use addresses, with a loopback exception only for development servers that themselves run on loopback (metadata document draft -02, section 8.6). The sign is metadata fetches to private addresses in the authorization server's logs.

Consent every few minutes. The shop's authorization server issues no refresh token, so the host runs the whole flow again whenever a token expires, and the user consents again. If you issue refresh tokens to hosts, which are public clients, the authorization server MUST rotate them, and MCP servers SHOULD NOT list offline_access in their challenges or scopes_supported (authorization). The sign is authorization requests from one host at the token lifetime's interval.

Step-up that can never succeed. A server whose 403 names a scope the authorization server doesn't know, or one the user's role can't grant, sends every such call through a second authorization that fails. The sign is authorization requests in pairs for one tool call, followed by a generic error in the host (Ask for more scope when a call needs it).

Working rule: Before the shop's server takes traffic, run three probes against the deployment: a request without a token gets 401 with a resource_metadata URL, a token for another resource gets 401, and a refund with a read-only token gets 403 with scope="refunds:write", and the same refund with a second Authorization header gets 400. Then check the metadata of every authorization server your hosts use for S256, iss support and metadata document support, and watch 401s, 403s and authorization requests per host.

Decisions this chapter made

  • The shop's server is an OAuth resource server over Streamable HTTP, configured with the SDK's AuthSettings: its endpoint's URL as resource_server_url, orders:read and stock:read as required_scopes, and validate_token_resource=True.
  • The examples use a teaching authorization server, lib.auth_server, not the SDK's built-in one: S256 only, resource required, iss sent and advertised, single-use codes, JWT access tokens with aud and scope, and no refresh tokens. It signs no one in, and its key derives from the book's public seed, so it must never be deployed. A deployment uses an established authorization server that offers S256 only, honours resource, sends and advertises iss, and supports Client ID Metadata Documents.
  • The support host is identified by a Client ID Metadata Document at an https:// URL on support.fernhill.example. In the examples the authorization server holds a local copy of the document in place of fetching it; Dynamic Client Registration isn't offered.
  • Hosts use the SDK's OAuthClientProvider with client_metadata_url, and add the PKCE support check the SDK leaves out.
  • Tokens are checked for audience by AuthSettings, and the token verifier reports aud as AccessToken.resource.
  • Scopes are per tool: orders:read for finding, reading and quoting orders, stock:read for stock, and refunds:write, which only support leads can grant, for refunds. An HTTP middleware decides on the parsed JSON-RPC body, with Mcp-Name only as a hint that must match it, and with the one token the SDK authenticates, refusing a second Authorization header. It answers a missing scope with a 403 challenge, and refuses tools and named methods that it doesn't map.
  • refunds:write arrives by step-up, never by default: it isn't in scopes_supported, and the SDK's client asks for the union of scopes when a refund needs it.
  • Every tool stays in tools/list, so that step-up can start. Hiding tools by scope is available in lib.tool_scopes and unused.
  • Who the host acts for comes from the verified token's sub, through get_access_token(), never from a tool argument: for the support host, a member of the staff. The customer a ticket concerns comes from the ticket record, which the server looks up and checks, not from the token or an argument. Only where customers sign in is sub the customer.

This chapter, in this edition

Edition 2026.11.5Examples passed Nov 4, 20267 of 7 examples in this chapter pass

Anyone can check this book or correct it. Found something wrong? Report it with the section's ID, the last part of the link beside each heading, or fix it yourself with a pull request.

Cite this section

Format

Every format names this edition. Its link opens the current edition, where a section’s heading says when its text last changed. Accessed and retrieved dates are the day you open this.

On this page

Chapter 8 of 12: Authorize clients with OAuth

  1. Find the authorization server from the resource
  2. Identify the client with a metadata document
  3. Get a token with PKCE
  4. Bind each token to one server
  5. Validate the issuer of each response
  6. Scope tools by what the token allows
  7. Ask for more scope when a call needs it
  8. What goes wrong in production
  9. Decisions this chapter made

Back to the topAll chapters