Skip to content

Credential boundaries

  • Lesson 19
  • 50 min
  • Module 6: Protect tools and user control

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 readyAbout 50 minutes

What you need to know

Your project

  • ~/projects/support-mcp after the last lesson: python -m pytest -q prints 84 passed.
  • The practice identity provider’s key from Module 5, in .idp/. If it is missing, run python -m idp init.
  • Three terminals in the project folder, each with the virtual environment active. Ports 8000 and 8400 must be free.

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.

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.

Two panels. Left, dashed red: token passthrough, forbidden by the specification. The host sends Sam's token A to the MCP server, which sends the same token A on to the ticket API. Notes: the ticket API sees Sam's token, not the server, so its logs name the wrong caller; token A must work at both, so its audience is wrong for one of them or for both; a stolen token A opens both doors; the server becomes a confused deputy, it lends its position to whoever holds the token. Right, green: support-mcp, one audience per hop. The host sends Sam's token with aud http://127.0.0.1:8000/mcp to support-mcp, which checks the audience and reads its own data with knowledge.py. A dashed arrow to the ticket API, if it had one: its own credential. A note: recorded, a token with aud ticket-api sent to support-mcp gets HTTP 401, and the server log says token_refused, reason wrong_audience; the token never leaves the server.
Token passthrough against one audience per hop. support-mcp accepts only tokens made for it, and never sends them on.
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.

  1. In terminal 1, start the identity provider:

    Terminal window
    python -m idp

    It prints nothing and keeps running.

  2. In terminal 2, start the server over Streamable HTTP:

    Terminal window
    python -m support_mcp --http

    It prints StreamableHTTP session manager started. Its log lines appear in this terminal.

  3. 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"
    {
    "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.

  4. 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 Unauthorized
    www-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 curl line in PowerShell; use curl.exe there, and watch the quoting of the JSON.

  5. 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%3Apropose

The 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.

Two rows. Before the fix, red: the resource metadata has no scopes_supported; the SDK falls back to the provider's list, all three; it asks for knowledge:read tickets:read refunds:propose; a reading app holds the write scope. After the fix, green: the resource metadata says scopes_supported knowledge:read tickets:read; the SDK asks for those two only; the token has knowledge:read tickets:read; propose_refund gets -32003 insufficient_scope. A note below: the write scope is granted separately, for the person and the task that need it, with python -m idp token --user usr-sam --scope knowledge:read tickets:read refunds:propose.
The scope finding of this course. When the server advertises only the read scopes, the SDK asks for no more.
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:

  1. 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"]}
  2. 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 Forbidden
    www-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.

  3. 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}
  4. 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.

  5. 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:

host/app.py (part, before)
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, USER
MODEL_API_KEY readable by the server: True

support-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.

host/app.py (part, after)
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"}
...
  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))"

    The list holds every variable of your shell, MODEL_API_KEY among them. Yours is longer than the course team’s, because a normal shell has more variables.

  2. Copy this lesson’s two files:

    Terminal window
    S=../nextia-learning-labs/C28/snapshots/end-of-m06
    cp "$S/host/app.py" host/
    cp "$S/tests/test_credentials.py" tests/
  3. Run the command of step 1 again:

    ['PYTHONUNBUFFERED', 'SUPPORT_TENANT', 'SUPPORT_USER']

    The course team’s child process now printed 10 variables and MODEL_API_KEY readable by the server: False.

  4. Run the tests. Expect 86 passed: the two tests in test_credentials.py are new. One starts a child process the way the SDK does and checks that it cannot read the key.

  5. On Windows, remove the practice value: Remove-Item Env:MODEL_API_KEY.

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:

The host, python -m host, sends requests to two places, both configured: the model API at MODEL_BASE_URL, and support-mcp at the --http URL. support-mcp sends requests to one place, configured: the provider's keys at OIDC_JWKS_URL. A dashed red box: never a destination: a URL in a ticket or a policy text; a URL in a tool argument the model wrote; the iss of a token, which is checked, never fetched; a URL that an unknown server's 401 or metadata names, the client's SSRF risk. A note: each part sends requests only to addresses from its own configuration; text that the model read, or a token, never chooses where a request goes.
Every outbound request of the course project, and the destinations that no text or token can choose.
Text description of the diagram
  • The host sends requests to the model API (MODEL_BASE_URL) and to the MCP server (the --http URL). Both come from its configuration.
  • support-mcp sends one kind of request: it fetches the provider’s public keys from OIDC_JWKS_URL, also configuration.
  • Never a destination: a URL in a ticket, a policy text or a tool argument. Never the iss of 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_settings catches 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.

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:

  1. An out-of-scope read fails. Sam’s server has only the scope knowledge:read, and the model asks for get_ticket T-30002. Then, with every scope, the model asks for Bramble’s T-40003.
  2. An unapproved write changes nothing. The model asks for propose_refund of 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
tests/test_module6_practice.py
"""Module 6 practice (worked solution): the model asks for an out-of-scope read and an unapproved
write; neither succeeds. Put this file in tests/ of the project at the end of Module 6."""
import anyio
from mcp import Client
from host import app
from host.models import Decision, ToolCall
from support_mcp import writes
from support_mcp.approvals import main as approvals
from 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.

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.

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.

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.

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.

  1. Out-of-scope read. The model asks for it, and the server refuses it. Examples: get_ticket with only knowledge:read gives −32003 insufficient_scope: this needs the scope tickets:read. get_ticket T-40003 on a Larkfield connection gives not_found … in this organization. Over HTTP, a token with only knowledge:read gets 403 insufficient_scope. In the attempts table, all 6 reads of the other organization’s ticket fail with not_found.
  2. Unapproved write. The obedient model asks for a 450-dollar refund, and the person says yes: over_limit, and approvals list is empty. Or a 25-dollar proposal is recorded as OP-0001 pending_approval, approvals refunds is empty, and approve … --user usr-sam is 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.

Recap

  • No token passthrough: support-mcp accepts only tokens whose audience is its own URL, and never sends a client’s token on. A token for ticket-api gets 401; the log says wrong_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.

Next: Contract and compatibility tests