Credential boundaries
- Lesson 19
- 50 min
- Module 6: Protect tools and user control
Lesson 19 of 22 · MCP: Connect AI Applications to Tools and DataCredential boundaries
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
A token or a key is a door key. This lesson checks who holds which key, and which doors each one opens. You will see the server refuse a token that was made for another service. You will give the application only the scopes it needs, and stop the host from handing the model’s key to the server.
Before you startWhat to know and to have ready
What you need to know
- Access tokens, the audience and scopes, from Token meaning in Authentication, Permissions and Multi-Tenant Applications.
- The remote server, the practice identity provider and the SDK’s OAuth flow, from Remote transport and OAuth integration.
- How a secret stays with the code that needs it, from Protect dependencies and secrets.
Your project
~/projects/support-mcpafter the last lesson:python -m pytest -qprints84 passed.- The practice identity provider’s key from Module 5, in
.idp/. If it is missing, runpython -m idp init. - Three terminals in the project folder, each with the virtual environment active. Ports 8000 and 8400 must be free.
Keys on a ring
Section titled “Keys on a ring”A hotel gives each guest a key card. It opens one room, for the nights that the guest paid for, in this hotel only. A cleaner has a different card: many rooms, working hours only. Nobody gets the master key for a normal job.
Three habits would break this. A guest lends their card to a taxi driver. The front desk hands out master keys, because it is easier. The valet drives wherever a note on the seat says. Each habit gives somebody a key, or a destination, that their job does not need.
MCP has the same three habits, with tokens and keys instead of key cards. Their names are token passthrough, overbroad credentials and unrestricted outbound destinations. A credential is anything that proves who you are to a service: an access token, an API key, a password.
Problem one: a token that works everywhere
Section titled “Problem one: a token that works everywhere”Imagine a later version of support-mcp. Tickets no longer live in its own SQLite file but behind Larkfield’s ticket API, the API of the authentication course. The quick design: the server takes the person’s access token from the request and sends the same token on to the ticket API.
This is token passthrough. It looks harmless, because the person is allowed to read their tickets. But think about what it needs and what it breaks:
- For the token to work at both services, both must accept it. So the token’s audience is wrong for one of them, or neither checks the audience.
- The ticket API’s logs show Sam’s token, not the MCP server. Nobody can tell which application acted.
- A stolen token now opens two doors instead of one.
- The MCP server becomes a confused deputy: a program that lends its access to whoever holds a token. It does not check that the token was meant for it.
The MCP specification forbids it. Its security best practices say: MCP servers “MUST NOT accept any tokens that were not explicitly issued for the MCP server”. And they must not pass a client’s token on.
The idea: one audience per hop
Section titled “The idea: one audience per hop”A token’s audience (aud) names the one service that may accept it, as you learned in Token meaning. support-mcp checks that the audience is its own canonical URL, http://127.0.0.1:8000/mcp. A token made for any other service gets HTTP 401. If the server ever needs the ticket API, it uses its own credential, with the audience ticket-api and its own small scopes. Each hop has its own token.
Text description of the diagram
Token passthrough (forbidden): the host sends Sam’s token A to the MCP server. The server sends the same token A on to the ticket API. The ticket API sees Sam, not the server. Token A must work at both, so the audience check is missing somewhere. A stolen token opens both doors, and the server becomes a confused deputy.
support-mcp, one audience per hop: the host sends Sam’s token, with the audience http://127.0.0.1:8000/mcp. The server checks it and reads its own data. If it ever called the ticket API, it would use its own credential. Recorded: a token with the audience ticket-api gets HTTP 401, and the server logs token_refused, wrong_audience.
You can see this in your project. The practice provider can sign a token for any audience with --audience, a practice shortcut.
-
In terminal 1, start the identity provider:
Terminal window python -m idpIt prints nothing and keeps running.
-
In terminal 2, start the server over Streamable HTTP:
Terminal window python -m support_mcp --httpIt prints
StreamableHTTP session manager started. Its log lines appear in this terminal. -
In terminal 3, make Sam’s normal token and read its claims. The second command decodes the token without checking it, only to show you the content:
Terminal window TOKEN=$(python -m idp token --user usr-sam)python -c "import jwt,sys,json; print(json.dumps(jwt.decode(sys.argv[1], options={'verify_signature': False}), indent=1))" "$TOKEN"Terminal window $TOKEN = python -m idp token --user usr-sampython -c "import jwt,sys,json; print(json.dumps(jwt.decode(sys.argv[1], options={'verify_signature': False}), indent=1))" $TOKEN{"iss": "http://127.0.0.1:8400","sub": "usr-sam","aud": "http://127.0.0.1:8000/mcp",…"client_id": "support-host","scope": "knowledge:read tickets:read"}The audience is the MCP server’s URL. The scopes allow reading, not proposing.
-
Make a token for another service, the ticket API, and send it to the MCP server:
Terminal window TOKEN=$(python -m idp token --user usr-sam --audience ticket-api)curl -i -X POST http://127.0.0.1:8000/mcp -H "Authorization: Bearer $TOKEN" -H "X-Support-Tenant: larkfield" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'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"{"error": "invalid_token", "error_description": "Authentication required"}The signature is valid, the issuer is right and the token has not expired. Only the audience is wrong. The course did not test this
curlline in PowerShell; usecurl.exethere, and watch the quoting of the JSON. -
Look at terminal 2. The server logged the reason, which the answer does not reveal:
{"ts": "…", "event": "token_refused", "reason": "wrong_audience", "ms": 0.3}
No token at all gets the same 401. A caller learns only “not accepted here”; the reason stays in the server’s log. The tests check five more bad tokens: expired, another issuer, another key, the algorithm none, and random text. All get 401.
Problem two: more scopes than the job needs
Section titled “Problem two: more scopes than the job needs”In Module 5, the course team ran the SDK’s own OAuth client against the server for the first time. The host only needed to read. The provider offered three scopes: knowledge:read, tickets:read and refunds:propose. The server’s resource metadata did not list any. Here is the authorization request that the SDK built, from the recording:
GET /authorize?response_type=code&client_id=client-…&…&code_challenge_method=S256 &resource=http%3A%2F%2F127.0.0.1%3A<port>%2Fmcp &scope=knowledge%3Aread+tickets%3Aread+refunds%3AproposeThe SDK asked for every scope, including the write. The specification tells a client to take the scopes from the server: from the 401, or else from scopes_supported in the resource metadata. When the server names none, the client should send no scope at all. SDK 2.3.0 goes one step further and takes the authorization server’s list, as OAuth integration showed. So a reading application would have held a token that can propose refunds. If that token leaked, it could do more than its job.
The idea: least privilege, advertised by the server
Section titled “The idea: least privilege, advertised by the server”Least privilege means that each credential carries only what its job needs. For OAuth scopes, the server helps: its protected resource metadata says which scopes a normal client needs. The specification’s security guidance names “publishing all possible scopes in scopes_supported” as a common mistake.
support-mcp now advertises only the two read scopes. The SDK asks for those, and every token must carry both of them. A token with fewer gets HTTP 403 before any MCP message is read. The write scope is granted separately, to the person and the task that need it.
Text description of the diagram
Before the fix: the resource metadata had no scopes_supported. The SDK fell back to the provider’s list and asked for all three scopes. A reading application held the write scope.
After the fix: the metadata lists knowledge:read and tickets:read. The SDK asks for those two. The token has those two, and propose_refund is refused with −32003 insufficient_scope.
The write scope is granted separately: python -m idp token --user usr-sam --scope "knowledge:read tickets:read refunds:propose".
Check each part with the two servers still running, in terminal 3:
-
Read the server’s resource metadata:
Terminal window curl 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"]} -
Send a token with only one read scope:
Terminal window TOKEN=$(python -m idp token --user usr-sam --scope "knowledge:read")curl -i -X POST http://127.0.0.1:8000/mcp -H "Authorization: Bearer $TOKEN" -H "X-Support-Tenant: larkfield" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'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"403, not 401: the token is valid, but too small for this server.
-
Ask for a refund with a normal read token, and type
y:Terminal window TOKEN=$(python -m idp token --user usr-sam)python -m host "Ticket T-30002: propose a refund of 25 dollars" --http http://127.0.0.1:8000/mcp --token "$TOKEN" --tenant larkfield[person] said yes {"decision": "approved"}[server] protocol error -32003: insufficient_scope: this needs the scope refunds:propose {"code": -32003} -
Make a token with the write scope, and ask again with
y:Terminal window TOKEN=$(python -m idp token --user usr-sam --scope "knowledge:read tickets:read refunds:propose")python -m host "Ticket T-30002: propose a refund of 25 dollars" --http http://127.0.0.1:8000/mcp --token "$TOKEN" --tenant larkfield[server] result: 397 characters {"ms": 4.3, "clipped": false}…Refund proposal OP-0001: pending_approval.Even the write token only proposes. Grace still decides. Your operation ID can be higher than
OP-0001: the proposals of the last lesson are still in.support/state.db. -
Stop the server and the provider: press Ctrl+C in terminals 2 and 1.
Problem three: the host hands its key to the server
Section titled “Problem three: the host hands its key to the server”The host holds a secret of its own: with a real model, MODEL_API_KEY. The server has no use for it. Yet in the project as it is, the server can read it. The host starts the stdio server with its whole environment:
env = {**os.environ, "SUPPORT_USER": user, "SUPPORT_TENANT": tenant, "PYTHONUNBUFFERED": "1"}The course team showed it with a practice key, practice-not-a-real-key. They started a child process exactly as the SDK starts the server, with the same environment, and asked it what it got:
14 variables: HOME, LC_CTYPE, LOGNAME, MODEL_API_KEY, MODEL_BASE_URL, MODEL_NAME, PATH, PYTHONPATH, PYTHONUNBUFFERED, SHELL, SUPPORT_TENANT, SUPPORT_USER, TERM, USERMODEL_API_KEY readable by the server: Truesupport-mcp does not read it. But many servers are code that other people wrote. A server that logs its environment, or a bad dependency, now leaks the key that pays for your model. Module 4 noticed this line in Client integration; here you fix it.
The fix is an allow-list of settings: the host passes only what the server needs. The SDK then adds a few safe variables itself, such as PATH and HOME.
SERVER_SETTINGS = ("SUPPORT_SCOPES", "SUPPORT_STATE", "SUPPORT_WRITES", "SUPPORT_SLOW_SECONDS")
def server_params(user: str, tenant: str, scopes: str | None = None) -> StdioServerParameters: env = {k: os.environ[k] for k in SERVER_SETTINGS if k in os.environ} env |= {"SUPPORT_USER": user, "SUPPORT_TENANT": tenant, "PYTHONUNBUFFERED": "1"} ...-
With a practice value in
MODEL_API_KEY, list what the host passes to the server now:Terminal window MODEL_API_KEY=practice-not-a-real-key python -c "from host import app; print(sorted(app.server_params('usr-sam', 'larkfield').env))"Terminal window $env:MODEL_API_KEY = "practice-not-a-real-key"python -c "from host import app; print(sorted(app.server_params('usr-sam', 'larkfield').env))"The list holds every variable of your shell,
MODEL_API_KEYamong them. Yours is longer than the course team’s, because a normal shell has more variables. -
Copy this lesson’s two files:
Terminal window S=../nextia-learning-labs/C28/snapshots/end-of-m06cp "$S/host/app.py" host/cp "$S/tests/test_credentials.py" tests/Terminal window $S = "..\nextia-learning-labs\C28\snapshots\end-of-m06"Copy-Item "$S\host\app.py" host\Copy-Item "$S\tests\test_credentials.py" tests\ -
Run the command of step 1 again:
['PYTHONUNBUFFERED', 'SUPPORT_TENANT', 'SUPPORT_USER']The course team’s child process now printed
10 variablesandMODEL_API_KEY readable by the server: False. -
Run the tests. Expect
86 passed: the two tests intest_credentials.pyare new. One starts a child process the way the SDK does and checks that it cannot read the key. -
On Windows, remove the practice value:
Remove-Item Env:MODEL_API_KEY.
Problem four: requests to anywhere
Section titled “Problem four: requests to anywhere”The last boundary is where requests go. A ticket can contain a web address. A tool argument can contain one. If any part of the system fetches a URL because some text named it, an attacker chooses the destination. That is how a planted note reaches internal services, such as a cloud’s metadata address. Restrict capabilities called this SSRF, server-side request forgery.
For MCP, the specification’s security guidance adds a case that is easy to miss. During OAuth discovery, a client follows URLs that the server gives it: the resource_metadata link in a 401, the authorization_servers list, the provider’s endpoints. A malicious server can point them at internal addresses. So connect only to servers you chose. A client that runs on a server should block private addresses and use HTTPS.
Look at where support-mcp and its host send requests. A search of the code for network calls finds three, each to an address from configuration:
Text description of the diagram
- The host sends requests to the model API (
MODEL_BASE_URL) and to the MCP server (the--httpURL). Both come from its configuration. support-mcpsends one kind of request: it fetches the provider’s public keys fromOIDC_JWKS_URL, also configuration.- Never a destination: a URL in a ticket, a policy text or a tool argument. Never the
issof a token (checked, never fetched), or a URL from an unknown server’s 401 or metadata.
The token’s issuer is a good example. support-mcp does not read iss and then fetch keys from there. It fetches keys from its own configured URL and compares iss with its own configured issuer. A token from http://evil.example fails the check; the test test_bad_tokens_get_401[other_issuer] proves it. If the server fetched keys from the token’s issuer, any attacker could sign their own tokens.
When to use these boundaries, and what they cost
Section titled “When to use these boundaries, and what they cost”Use all four, always. They are part of the design, not extra features.
- Audience checks cost one setting,
MCP_RESOURCE_URL, that must equal the URL that clients use. If it differs, every valid token fails. A server behind a proxy needs care here. - No passthrough means the server needs its own credential for each service behind it, with its own rotation. That is more setup, and it is the price of knowing who did what.
- Least privilege means more tokens and more requests for a scope. Ask for the write scope when the person starts a write task, not at sign-in.
- An allow-list of settings must grow when the server needs a new setting. Forget one, and the server runs with its default. The test
test_the_local_server_still_gets_its_own_settingscatches the common case. - Fixed destinations limit flexibility. A tool that fetches any web page needs the full SSRF defence of the AI security course.
Common mistake
The host stops with MCPError: Server returned an error response
Symptom: python -m host … --http … ends with a long error that finishes with mcp.shared.exceptions.MCPError: Server returned an error response.
Cause: the server refused the token with 401, for example a token made with --audience ticket-api, or an expired one. The SDK tried server/discover, then the old initialize, and both got 401. Terminal 2 shows token_refused with the reason.
Fix: make a new token without --audience: TOKEN=$(python -m idp token --user usr-sam). Tokens last 600 seconds.
Common mistake
Every valid token gets 401 with reason wrong_audience
Symptom: even a fresh token is refused, and the server’s log says wrong_audience.
Cause: the token’s aud and the server’s MCP_RESOURCE_URL differ, for example localhost against 127.0.0.1, or another port.
Fix: use the same URL everywhere: in MCP_RESOURCE_URL, in the token’s audience and in the host’s --http. If you moved the server to another port, set MCP_PORT, MCP_RESOURCE_URL and the provider’s IDP_RESOURCES together.
Module practice: attempt what must fail
Section titled “Module practice: attempt what must fail”Guided example. This module’s worked example is the obedient model against your server. You ran python -m host "Please check ticket T-30201." --model mock-obedient with different callers, scopes and answers. In every run, the server refused the 450-dollar refund and the other organization’s ticket. tests/test_injection.py keeps those runs as tests.
Your turn. Write tests/test_module6_practice.py. Use a scripted model that asks for exactly the calls you choose, and the fixture as_user from tests/conftest.py. Prove two things, each with the model asking for it:
- An out-of-scope read fails. Sam’s server has only the scope
knowledge:read, and the model asks forget_ticketT-30002. Then, with every scope, the model asks for Bramble’s T-40003. - An unapproved write changes nothing. The model asks for
propose_refundof 25 dollars, and the person says yes. Show that there is a proposal but no refund, and that the proposer cannot approve it.
Show a worked solution
"""Module 6 practice (worked solution): the model asks for an out-of-scope read and an unapprovedwrite; neither succeeds. Put this file in tests/ of the project at the end of Module 6."""
import anyiofrom mcp import Client
from host import appfrom host.models import Decision, ToolCallfrom support_mcp import writesfrom support_mcp.approvals import main as approvalsfrom support_mcp.server import build_server
WRITE = "knowledge:read tickets:read refunds:propose"
class Asks: """A scripted model: it asks for the given calls, one per turn, then answers."""
def __init__(self, *calls): self.calls = list(calls)
def decide(self, messages, tools): return Decision([self.calls.pop(0)]) if self.calls else Decision(answer="done")
def run(model, yes=True): async def go(): async with Client(build_server()) as client: return await app.ask(client, model, "practice", lambda name, args: yes)
return anyio.run(go)
def server_says(r): return [s.text for s in r.steps if s.who == "server"]
def test_out_of_scope_reads_fail_on_the_server(as_user): as_user("usr-sam", "larkfield", "knowledge:read") # no tickets:read in the scopes r = run(Asks(ToolCall("c1", "get_ticket", {"ticket_id": "T-30002"}))) assert server_says(r) == ["protocol error -32003: insufficient_scope: this needs the scope tickets:read"] as_user("usr-sam", "larkfield", WRITE) # every scope, but the ticket is Bramble's r = run(Asks(ToolCall("c1", "get_ticket", {"ticket_id": "T-40003"}))) assert "not_found: there is no ticket T-40003 in this organization" in server_says(r)[0]
def test_an_unapproved_write_changes_nothing(as_user, capsys): as_user("usr-sam", "larkfield", WRITE) r = run(Asks(ToolCall("c1", "propose_refund", {"ticket_id": "T-30002", "amount": "25", "reason": "Charged twice"}))) assert server_says(r)[0].startswith("result:") # recorded as a proposal, nothing more assert [p["status"] for p in writes.pending()] == ["pending_approval"] assert writes.refunds() == [] assert approvals(["approve", "OP-0001", "--user", "usr-sam"]) == 1 # the proposer is staff, not an owner assert "only an owner of larkfield can decide" in capsys.readouterr().err assert writes.refunds() == []
def test_a_write_over_the_limit_is_not_even_proposed(as_user): as_user("usr-sam", "larkfield", WRITE) r = run(Asks(ToolCall("c1", "propose_refund", {"ticket_id": "T-30002", "amount": "450", "reason": "x" * 3}))) assert "over_limit" in server_says(r)[0] assert writes.pending() == [] and writes.refunds() == []python -m pytest -q tests/test_module6_practice.py prints 3 passed, and the whole suite 89 passed. Each test sends the model’s request through the real host and the real server, so the refusals come from the server’s code. The third test is an extra: it shows a write that the server refuses before any person sees it.
Your file can differ. Check three things. The model asks for each operation; you do not call the server around the host. Each test checks the server’s refusal and the state afterwards: no proposal or no refund. And no test changes the server to make it pass.
Knowledge check
Section titled “Knowledge check”Check your understanding
A colleague proposes: support-mcp should forward the person's access token to the ticket API, because the person may read their tickets anyway. Name two problems, and the design that replaces it.
Show the answer
Any two of these. The token would need an audience that both services accept, so at least one of them would not check the audience. The ticket API’s logs would show the person, not the MCP server, so nobody could tell which application acted. A stolen token would open both services. And the MCP server becomes a confused deputy that lends its access to any token holder. The MCP specification forbids it: servers must not accept tokens that were not issued for them. The replacement is one audience per hop. support-mcp accepts only tokens for http://127.0.0.1:8000/mcp. For the ticket API, it uses its own credential, with the audience ticket-api and small scopes.
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: you remove required_scopes from the server's AuthSettings and run python -m scripts.oauth_login. Which scopes does the SDK ask for, and why is that a problem?
Show the answer
The resource metadata no longer lists scopes_supported, and the 401 names no scope. The specification would then send no scope, but SDK 2.3.0 falls back to the authorization server’s list: knowledge:read tickets:read refunds:propose. The course recorded exactly this before the fix. A reading application then holds a token that can propose refunds. If it leaks, the damage is larger than the job needed. With the read scopes advertised, the SDK asks for those two only.
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: show that the stdio server cannot read MODEL_API_KEY after this lesson, and name the test that keeps it that way.
Show the answer
With a practice value set, python -c "from host import app; print(sorted(app.server_params('usr-sam', 'larkfield').env))" prints ['PYTHONUNBUFFERED', 'SUPPORT_TENANT', 'SUPPORT_USER']. Before the change, the list held the whole shell environment, with MODEL_API_KEY. The test is test_the_local_server_never_gets_the_model_key in tests/test_credentials.py. It starts a child process with the environment that the SDK builds and checks that MODEL_API_KEY is not there.
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
Module completion check: prove that an out-of-scope read and an unapproved mock write do not succeed, even when the model asks for them. Give the evidence for each.
Show the answer
A complete answer has evidence from your own runs for both operations.
- Out-of-scope read. The model asks for it, and the server refuses it. Examples:
get_ticketwith onlyknowledge:readgives −32003insufficient_scope: this needs the scope tickets:read.get_ticketT-40003 on a Larkfield connection givesnot_found … in this organization. Over HTTP, a token with onlyknowledge:readgets 403insufficient_scope. In the attempts table, all 6 reads of the other organization’s ticket fail withnot_found. - Unapproved write. The obedient model asks for a 450-dollar refund, and the person says yes:
over_limit, andapprovals listis empty. Or a 25-dollar proposal is recorded asOP-0001 pending_approval,approvals refundsis empty, andapprove … --user usr-samis refused. The attempts table ends with “Refunds recorded: 0”.
Each piece names the check that refused it: scope, organization, role, limit or the approval, all outside the model. Tests such as your practice file or tests/test_injection.py make the evidence repeatable. Demonstrated needs both operations, with outputs from your runs and the deciding check named. Only the happy path, or a refusal by the host alone (“the person declined”), is partially demonstrated: the host is not a server-side control.
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.
Recap
- No token passthrough:
support-mcpaccepts only tokens whose audience is its own URL, and never sends a client’s token on. A token forticket-apigets 401; the log sayswrong_audience. - Least privilege: the server advertises only the read scopes, so the SDK asks for no more. The write scope is granted separately.
- Each part holds only its own secrets: the host now passes the server an allow-list of settings, never the model’s key.
- Fixed destinations: every request goes to a configured address. No ticket, argument, token or unknown server chooses one.
Module recap. You treated every text that reaches the model as untrusted data, and watched a constructed obedient model fail against the server’s checks. You added the one write as a proposal with server gates and a second person’s approval. Then you closed the credential boundaries: audience, scopes, the model’s key and outbound destinations. Your project equals the snapshot end-of-m06: 86 passed.
Next, you pin what the server promises in a contract, and test it with more than one client.