OAuth integration
- Lesson 16
- 55 min
- Module 5: Remote access and authorization
Lesson 16 of 22 · MCP: Connect AI Applications to Tools and DataOAuth integration
1. Understand the integration problem
3. Build and inspect a local server
4. Connect an AI application
5. Remote access and authorization
6. Protect tools and user control
7. Operate and maintain the integration
What you will be able to do
Let any MCP client get a token for Larkfield’s server the official way, with no shortcut. You follow the flow step by step: the server’s 401, two metadata documents, registration, sign-in with PKCE, and a token for this server. Then you watch the SDK’s own client do it in one call, and fix the scopes that it asked for.
Before you startWhat to know and to have ready
What you need to know
- The 401 and its
resource_metadatalink, from Remote transport. - How the server turns a token into a caller, from Identity and permission.
- The authorization code flow, PKCE and the audience check, from OAuth and OIDC and Token meaning.
Your project
~/projects/support-mcpafter Identity and permission:python -m pytest -qprints63 passed.- Terminal 1 runs
python -m idp, terminal 2 runspython -m support_mcp --http. - The by-hand steps use a macOS or Linux shell. On Windows, use Git Bash or WSL for them (not tested), or skip to
python -m scripts.oauth_login.
A key card at a hotel you have never visited
Section titled “A key card at a hotel you have never visited”The authentication course used a hotel. The reception desk checks your passport and gives you a key card. The card opens room 214 and the gym, and it stops working when you check out. The door never sees your passport; it only checks the card.
Now imagine a new guest at a hotel they do not know. They find a locked door. A sign on the door says which reception desk issues its cards. At that desk, a board lists the services: where to sign in, where to collect the card. The guest gives their name once and gets a guest number. Then they sign in, and they get a card for that one door only. The phone digits from the authentication course still apply: the person who collects the card must prove that they started the request.
The problem: a new client knows nothing
Section titled “The problem: a new client knows nothing”So far, the tokens came from python -m idp token, a practice shortcut on the same computer. Omar’s partner app has no such shortcut. It knows one thing: the URL http://127.0.0.1:8000/mcp. It does not know which provider issues tokens, which scopes to ask for, or what the audience must be. It has no client ID either.
Its first request gets this answer, which you saw in Remote transport:
HTTP/1.1 401 Unauthorizedwww-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"There is a second risk. A careless client could ask for every scope the provider offers, or reuse a token made for another service. The course team met the first one in a real run, as you see later in this lesson.
The idea: the MCP authorization flow
Section titled “The idea: the MCP authorization flow”MCP authorization uses OAuth with a few standard documents. The parts have names that you saw in the authentication course, plus three new ones.
The MCP server is a resource server: it accepts tokens, and it never signs anyone in. The provider is the authorization server: it signs people in and issues tokens. The server publishes its protected resource metadata at a well-known URL (RFC 9728). That document names the server’s own URL, the resource, and its authorization servers. The provider publishes its own authorization server metadata (RFC 8414): its endpoints and what it supports.
A new client can register itself with dynamic client registration (RFC 7591), and gets a client ID. Revision 2026-07-28 prefers another way, a client ID that is the URL of a metadata document; it keeps dynamic registration, marked deprecated, for providers that lack that way. The practice provider and SDK 2.3.0’s client use dynamic registration. Then it runs the authorization code flow with PKCE (said “pixie”). It also sends a resource parameter (RFC 8707) with the server’s URL. The provider writes that URL into the token as its audience. So a token for this server works only here.
Text description of the diagram
- The host sends
POST /mcpwith no token. The server answers 401, withresource_metadatapointing to/.well-known/oauth-protected-resource/mcp. - The host reads that document: the resource
:8000/mcp, the authorization server:8400, andscopes_supportedknowledge:read,tickets:read. - The host reads the provider’s metadata at
/.well-known/oauth-authorization-server: the authorize, token and register endpoints, the JWKS and the method S256. - The host registers with
POST /registerand a redirect URI on127.0.0.1, and gets aclient_id. - The host opens
/authorizefor the person, with the scopes, theresourceand a PKCE code challenge. The person signs in; here they pick a made-up person. - The provider redirects to the host’s redirect URI with a code. The host sends the code, the code verifier and the resource to
POST /token, and gets an access token: Bearer, 600 seconds, audience:8000/mcp. - The host sends
POST /mcpwith the token andX-Support-Tenant: larkfield. The server checks the token with the provider’s JWKS: issuer, audience equal to its own URL, expiry. Then it checks membership, scope and lookup, and answers 200.
A tiny example: PKCE with every value visible
Section titled “A tiny example: PKCE with every value visible”The code from step 6 travels through the browser, in a redirect URL. Someone could copy it on the way. PKCE makes a copied code useless. Here is the example of RFC 7636, the standard that defines PKCE, with every value.
Text description of the diagram
The host makes a random code verifier and keeps it: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk. Its SHA-256 hash, written in base64url without the = at the end, is the code challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM.
- The host sends the challenge to
/authorize, withcode_challenge_method=S256. The provider keeps it with the code. - The host sends the code and the verifier to
POST /token. The provider hashes the verifier and compares. The same value gives a token, and the code works once.
Someone who copies the code but has no verifier gets {"error": "invalid_grant", "error_description": "Invalid code verifier."}. The same code a second time gets "Unknown, used or expired code.". Both answers are real.
The hash goes one way only. Anyone can see the challenge, but nobody can compute the verifier from it. This is the phone-digits idea of the authentication course. You say the last four digits at the start, and only you can name the whole number at the end.
Larkfield: the flow by hand
Section titled “Larkfield: the flow by hand”You run each step with curl against the practice provider. The provider has no passwords: the sign-in page lets you pick a made-up person. This is for learning only.
-
Copy this lesson’s files, and run the tests:
Terminal window S=../nextia-learning-labs/C28/snapshots/end-of-m05cp "$S/tests/test_idp.py" "$S/tests/test_oauth.py" tests/cp "$S/scripts/oauth_login.py" scripts/cp "$S/docs/remote-design.md" docs/python -m pytest -q.................................................................... [100%]68 passed in 11.39sIn PowerShell, copy the same four files with
Copy-Item, as in the earlier lessons. -
Read the server’s protected resource metadata:
Terminal window curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp{"resource":"http://127.0.0.1:8000/mcp","authorization_servers":["http://127.0.0.1:8400"],"scopes_supported":["knowledge:read","tickets:read"],"bearer_methods_supported":["header"]}resourceis the server’s canonical URL: the audience that its tokens must name. -
Read the provider’s metadata:
Terminal window curl -s http://127.0.0.1:8400/.well-known/oauth-authorization-server | python -m json.tool{"issuer": "http://127.0.0.1:8400","authorization_endpoint": "http://127.0.0.1:8400/authorize","token_endpoint": "http://127.0.0.1:8400/token","registration_endpoint": "http://127.0.0.1:8400/register","jwks_uri": "http://127.0.0.1:8400/jwks.json","scopes_supported": ["knowledge:read","tickets:read","refunds:propose"],"response_types_supported": ["code"],"grant_types_supported": ["authorization_code"],"code_challenge_methods_supported": ["S256"],"token_endpoint_auth_methods_supported": ["none"]}Note
refunds:propose: the provider offers a write scope that the server does not advertise. -
Register a client, and keep its ID:
Terminal window CLIENT=$(curl -s -X POST http://127.0.0.1:8400/register -H "Content-Type: application/json" -d '{"client_name": "my-host", "redirect_uris": ["http://127.0.0.1:8765/callback"]}' | python -c 'import json,sys; print(json.load(sys.stdin)["client_id"])')echo $CLIENTclient-e3fd4aab3604The provider’s full answer is
{"client_name":"my-host","redirect_uris":["http://127.0.0.1:8765/callback"],"client_id":"client-e3fd4aab3604","client_id_issued_at":…,"token_endpoint_auth_method":"none"}.nonemeans a public client: it has no secret, so PKCE protects it. Your ID differs. -
Make a PKCE pair:
Terminal window read VERIFIER CHALLENGE < <(python -c "import secrets,hashlib,base64; v=secrets.token_urlsafe(48); print(v, base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).rstrip(b'=').decode())")echo ${#VERIFIER} ${#CHALLENGE}64 43The verifier has 64 characters; the provider accepts 43 to 128. The challenge always has 43.
-
Build the sign-in address, and ask for a code as Sam:
Terminal window AUTH="http://127.0.0.1:8400/authorize?response_type=code&client_id=$CLIENT&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=knowledge%3Aread%20tickets%3Aread&resource=http%3A%2F%2F127.0.0.1%3A8000%2Fmcp&code_challenge=$CHALLENGE&code_challenge_method=S256&state=xyz"curl -s -i "$AUTH&login_hint=usr-sam" | grep -i -E '^(HTTP|location)'HTTP/1.1 302 Foundlocation: http://127.0.0.1:8765/callback?code=Lcr39eFHq1ki1M2RC9Uec02GBXCC0Xq_&state=xyz&iss=http%3A%2F%2F127.0.0.1%3A8400login_hintis a practice shortcut that skips the sign-in page. Open$AUTHwithout it in a browser to see the page: “Practice sign-in. Made-up people only. Pick one:”. Nothing listens on port 8765. So after you pick, a browser shows an error, with the code in its address bar (not tested by the course). -
Exchange the code for a token. Copy the code from the
locationline:Terminal window CODE=Lcr39eFHq1ki1M2RC9Uec02GBXCC0Xq_curl -s -X POST http://127.0.0.1:8400/token -d "grant_type=authorization_code&code=$CODE&client_id=$CLIENT&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&resource=http%3A%2F%2F127.0.0.1%3A8000%2Fmcp&code_verifier=$VERIFIER" > token.jsonpython -c 'import json; t=json.load(open("token.json")); print(t["token_type"], t["expires_in"], t["scope"])'Bearer 600 knowledge:read tickets:readA code lives 60 seconds. If this step says
"Unknown, used or expired code.", repeat step 6 and be faster. -
Use the token with the host:
Terminal window SIGNED=$(python -c 'import json; print(json.load(open("token.json"))["access_token"])')python -m host "Draft a reply to ticket T-30002" --http http://127.0.0.1:8000/mcp --token "$SIGNED" --tenant larkfield[host] discovered 2 tools: search_knowledge, get_ticket {"protocol": "2026-07-28", "server": "support-mcp"}[model] wants get_ticket {"arguments": {"ticket_id": "T-30002"}}...Ticket T-30002 (open): Charged twice for order LK-182074.Decode it as in Remote transport:
audishttp://127.0.0.1:8000/mcp, from yourresourceparameter, andclient_idis your registered client. -
Delete
token.json. It holds a working token for ten minutes.
Read the provider
Section titled “Read the provider”idp/app.py is about 140 lines of Starlette. The token endpoint shows the checks of steps 6 and 7 in order:
c = _codes.pop(form.get("code", ""), None) # a code works onceif c is None or c["expires"] < time.time(): return error("invalid_grant", "Unknown, used or expired code.")if form.get("client_id") != c["client_id"] or form.get("redirect_uri") != c["redirect_uri"]: return error("invalid_grant", "The code was issued to another client or redirect URI.")verifier = form.get("code_verifier", "")if not 43 <= len(verifier) <= 128: return error("invalid_grant", "The code verifier must have 43 to 128 characters (RFC 7636).")digest = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()if digest != c["challenge"]: return error("invalid_grant", "Invalid code verifier.")pop removes the code before any other check, so a wrong verifier also uses up the code. /authorize refuses a resource that the provider does not know (invalid_target), and any PKCE method except S256. /register accepts redirect URIs on 127.0.0.1 or localhost only. Signing uses PyJWT and cryptography: there is no hand-written cryptography. It is a simplified copy of the authentication course’s provider. Never use it for real accounts.
The SDK’s client does it all
Section titled “The SDK’s client does it all”A real host does not run nine curl commands. The MCP Python SDK has an OAuth client, OAuthClientProvider, that does every step after a 401. scripts/oauth_login.py connects with it. It starts its own provider and server on free ports, so stop nothing:
python -m scripts.oauth_loginPOST /mcp -> 401GET /.well-known/oauth-protected-resource/mcp -> 200GET /.well-known/oauth-authorization-server -> 200POST /register -> 201authorize: client client-e1177552f893, scope 'knowledge:read tickets:read', resource http://127.0.0.1:<server>/mcp, PKCE S256callback: got an authorization codePOST /token -> 200POST /mcp (with a token) -> 200POST /mcp (with a token) -> 200POST /mcp (with a token) -> 200token: Bearer, expires in 600 s, scope 'knowledge:read tickets:read'get_ticket T-30002 -> ok: Charged twice for order LK-182074These are the steps of the flow diagram, in the same order. The script skips the sign-in page with login_hint; a real host opens the page in the person’s browser. The three POST /mcp with a token are server/discover, the tools/call, and a tools/list that the SDK sent by itself, to check the result against the tool’s output schema. The server’s log lines and the SDK’s HTTP Request: lines go to stderr.
A real surprise: the SDK asked for every scope
Section titled “A real surprise: the SDK asked for every scope”The first run of this script, before the fix you see now, asked the provider for this:
&scope=knowledge%3Aread+tickets%3Aread+refunds%3AproposeThe script’s own client said scope="knowledge:read tickets:read". The SDK replaced it with all three scopes, including the write scope. A host that only reads would have held a token that can propose refunds.
Text description of the diagram
SDK 2.3.0 takes the first list of scopes that it finds, in this order, and replaces the client’s own scope with it:
- a
scopein the server’s 401WWW-Authenticateheader (none here); scopes_supportedin the server’s protected resource metadata;scopes_supportedin the provider’s metadata, the SDK’s own fallback.
Before the fix, the server advertised no scopes, so the SDK used the third list: knowledge:read tickets:read refunds:propose. After the fix, required_scopes lists the two read scopes, the resource metadata advertises only those, and the SDK asks for knowledge:read tickets:read. The write scope is granted separately.
The first two steps follow the MCP specification’s scope selection; the third is a fallback in the SDK’s code (get_client_metadata_scopes). The fix is on the server, because the server knows what it needs:
READ_SCOPES = ("knowledge:read", "tickets:read")... required_scopes=list(READ_SCOPES),required_scopes does two things. The metadata advertises exactly these scopes, so a client asks for no more. And the SDK refuses a token without them with HTTP 403 insufficient_scope, before MCP. Asking for the least that works is least privilege. Credential boundaries adds the write scope as a separate grant.
The audience: a token for one server only
Section titled “The audience: a token for one server only”The resource parameter is why the token’s aud is the server’s URL. The server’s check refuses everything else. Here is a real token that the provider made for another API, ticket-api, sent to this server:
HTTP/1.1 401 Unauthorizedwww-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"The server’s log names the reason: "event": "token_refused", "reason": "wrong_audience". The rule also works the other way. This server never sends a person’s token on to another service, and another service must refuse it too. That rule has a name, no token passthrough, and Credential boundaries tests it.
When to use this flow, and what it costs
Section titled “When to use this flow, and what it costs”Use it for any client that a person you do not control runs: a desktop assistant, a code editor, a partner’s app. They can find the provider, register and sign the person in without any setup from you. Use a simpler way only for tests and scripts on your own computer, as the course’s python -m idp token does, and label it.
The costs. The provider is a new service to run, and if it is down, nobody can sign in. Redirect URIs, the audience and the scopes are security settings: one mistake is a real bug. Open registration lets anyone create a client, so a real provider limits it. Tokens expire after minutes, and the practice provider issues no refresh token, so the person signs in again.
What the course did not test. A real identity provider such as Keycloak or Microsoft Entra ID, HTTPS, refresh tokens, and any desktop assistant or code editor as the client. A real provider may differ: some write typ: JWT instead of at+jwt, as the authentication course showed, and some do not support dynamic registration.
Common mistakes
Section titled “Common mistakes”Common mistake
invalid_target: Unknown resource
Symptom: /authorize answers {"error":"invalid_target","error_description":"Unknown resource: 'https://api.example.com/tickets'."}.
Cause: the resource parameter names a server that the provider does not issue tokens for. The provider’s list is IDP_RESOURCES; by default http://127.0.0.1:8000/mcp and http://localhost:8000/mcp.
Fix: send the server’s exact URL from its protected resource metadata. If you moved the server to another port, add its URL to IDP_RESOURCES in the provider’s terminal, and restart the provider.
Common mistake
invalid_request: PKCE with S256
Symptom: /authorize answers {"error":"invalid_request","error_description":"This provider needs response_type=code and PKCE with S256."}.
Cause: the request used code_challenge_method=plain, or no challenge. The plain method sends the secret itself at the start, so it protects nothing.
Fix: send the SHA-256 challenge with code_challenge_method=S256, as in step 5.
Reset. If your project is broken, copy the whole snapshot end-of-m05, as in Where MCP fits. Delete .idp/ and run python -m idp init to make a new practice key.
Knowledge check
Section titled “Knowledge check”Check your understanding
A new client knows only the server's URL. Name the two metadata documents it reads, and one fact it learns from each.
Show the answer
The server’s protected resource metadata, at /.well-known/oauth-protected-resource/mcp: the resource URL (the audience), the authorization server and the scopes to ask for. The provider’s authorization server metadata, at /.well-known/oauth-authorization-server: the authorize, token and register endpoints, the JWKS, and that PKCE must use S256. The 401’s resource_metadata header points to the first one.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Predict: someone copies the code from the redirect URL and sends it to /token with a verifier of their own. What happens, and can the real host still use the code?
Show the answer
The provider answers invalid_grant, “Invalid code verifier.”: the SHA-256 of their verifier does not match the challenge. The provider removes the code before it checks the verifier, so the real host then gets “Unknown, used or expired code.” and must start again. Nobody gets a token from a copied code.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Practical check: run python -m scripts.oauth_login. Which scope did the SDK ask for, and what makes it ask for exactly that?
Show the answer
scope 'knowledge:read tickets:read'. The server sets required_scopes to the two read scopes, so its protected resource metadata advertises only them. SDK 2.3.0 takes the resource metadata’s scopes_supported before the provider’s list. Without that setting, the SDK fell back to the provider’s list and asked for refunds:propose too.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Practice: design and test remote access
Section titled “Practice: design and test remote access”This practice ends the module. First you read a worked example, then you change one condition yourself.
Guided practice: the worked example
Section titled “Guided practice: the worked example”Open docs/remote-design.md. It is Amira’s remote deployment design for support-mcp. It has the table from Remote transport, the request flow of this lesson in five steps, and four decisions:
- The organization comes from the connection, never from the model.
- The role comes from the server’s membership table, never from the token.
- The server never forwards the user’s token.
- Production needs HTTPS, a real identity provider, a token lifetime of minutes, logs without tokens, and a rate limit per caller.
Her evidence is python -m scripts.attempts, with 48 attempts by synthetic identities: 15 allowed, 33 refused on the server, each with its check named. Read the design next to the table. Every decision in the design has at least one refused row as its proof.
Independent task: a search-only partner
Section titled “Independent task: a search-only partner”Omar’s partner app needs only one thing: to search Larkfield’s policies. It must never read a ticket. Change one condition, the scopes, and work it out:
- Make a token with only the search scope, and predict what the server answers to a search:
python -m idp token --user usr-omar --scope knowledge:read. - Try it with the host and a policy question. Explain the answer from the code of
http.py. - Decide how the server must change so that the partner can search but not read tickets. Make the change in your project, and test both requests again.
- Run the tests. Explain each failure: a bug, or a test that pinned the old design?
- Add a section “Partner access” to
docs/remote-design.md: the scopes, the evidence, and what remains open.
Show the worked solution
1 and 2. The project as it is refuses every request of that token before MCP. The host prints a long traceback that ends in MCPError: Server returned an error response. With curl, the answer is clearer:
HTTP/1.1 403 Forbiddenwww-authenticate: Bearer error="insufficient_scope", error_description="Required scope: tickets:read", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"required_scopes lists both read scopes, so the SDK’s HTTP layer demands both for every request, a search too.
3. Each tool already checks its own scope: search_knowledge asks for knowledge:read, and get_ticket asks for tickets:read. So the HTTP layer only needs the scope that every request needs. In support_mcp/http.py:
READ_SCOPES = ("knowledge:read",)Restart the server. The metadata now advertises one scope:
{"resource":"http://127.0.0.1:8000/mcp","authorization_servers":["http://127.0.0.1:8400"],"scopes_supported":["knowledge:read"],"bearer_methods_supported":["header"]}The partner’s search works, and the ticket is refused by the tool’s own check:
[server] result: 1078 characters {"ms": 196.8, "clipped": false}...From "Returns policy" (policy://larkfield/returns-policy): … ## Return window You can return an unused item …[server] protocol error -32003: insufficient_scope: this needs the scope tickets:read {"code": -32003}...I could not use get_ticket: insufficient_scope: this needs the scope tickets:read4. 3 failed, 65 passed. The three failures are test_a_token_without_the_read_scopes_is_refused_before_mcp, test_the_resource_metadata_advertises_only_the_read_scopes and test_the_sdk_client_signs_in_and_asks_only_for_the_advertised_scopes. They are not bugs: they pinned the old design on purpose. Update them to the new design, and add a test that a knowledge:read token gets −32003 for get_ticket. A changed security test is a decision: write it down.
5. A good section names the trade-off. The SDK’s client now asks only for knowledge:read. So the help desk’s host, which needs tickets, must ask for tickets:read itself, or the 401 must name the scope. Stopping a missing scope at the HTTP layer gave one clear 403. Now a ticket read without the scope is a −32003 inside MCP. Both are refusals on the server. The course team ran steps 1 to 4 exactly like this; your times differ.
Completion check: refuse on the server, not in the host
Section titled “Completion check: refuse on the server, not in the host”Show that the server itself refuses three kinds of access, with no help from the host. With the provider and the server running, send each request with curl or the course’s host:
- no token at all;
- a token for another API:
python -m idp token --user usr-sam --audience ticket-api; - another organization’s data: Camille’s valid token with
X-Support-Tenant: larkfieldand ticket T-40003, and Ines’s valid token withX-Support-Tenant: larkfield.
For each one, write the status or error, and the server’s log line or the test that proves it.
Show the worked solution
- No token:
HTTP/1.1 401 Unauthorized,www-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="…". No MCP request reached a tool. Test:test_no_token_gets_401_and_where_to_sign_in. - Wrong audience: the same 401, on purpose: the answer tells a stranger nothing. The server’s log says
"event": "token_refused", "reason": "wrong_audience". Test:test_bad_tokens_get_401[wrong_audience]. - Another organization: Camille at Larkfield asking for T-40003 gets the tool error
not_found: there is no ticket T-40003 in this organization; withX-Support-Tenant: brambleshe gets the ticket. Ines at Larkfield gets −32003forbidden: you have no access to this organization. Tests:test_a_cross_tenant_id_is_not_foundandtest_another_organization_is_refused_on_the_server.
Why “not in the host”? curl has no allow-list, no confirmation and no model. Every refusal above came from the server alone. python -m pytest -q tests/test_http.py prints 14 passed and repeats all of them.
Demonstrated means: all three refused, each with its evidence, and an explanation of why a host setting is not enough. Partially demonstrated: the refusals happen, but the evidence or the explanation is missing for one. Not yet demonstrated: any of the three requests succeeds.
Module recap
Section titled “Module recap”Recap
- Streamable HTTP serves the same tools at one URL. In revision 2026-07-28, each request stands alone, with no session. A remote server binds to
127.0.0.1for practice and needs HTTPS beyond your computer. - Each request carries an access token. Its audience is the server’s own URL, so a token for another API is refused.
- Authentication proves who; authorization decides what, on the server, for every request: token, membership, scope, role, organization-scoped lookup. The organization comes from the connection; the role from the server’s own table.
- A new client gets a token the official way. It follows the 401 to the protected resource metadata and the provider’s metadata, registers, and runs the code flow with PKCE and the
resourceparameter. - Advertise only the scopes the client needs. SDK 2.3.0 otherwise asks for every scope the provider offers.
Your project now has 68 tests, the practice provider and docs/remote-design.md. In Module 6, the AI application meets untrusted text in tickets and tool results, and the server gains its first write.