Key takeaways
- ✓The token decides which world you see: with an acme token, Dana Kim is 0 matches; with a globex token, the same call returns c_201. Only org_id differs.
- ✓Isolation lives in Postgres: the app connects as app_user, not the owner; with no tenant set it sees 0 rows, and per call it sees only the token's tenant. Even a buggy query can't cross.
- ✓401 means "who are you?"; 403 means "I know who you are, and you're not allowed". A 403 insufficient_scope names the missing scope.
- ✓When the Inspector's step-up attempt fails, ask the server directly with curl: the error on screen can be about the authorization server, not the MCP server's answer.
- ✓Every write attempt is audited, including denied ones. Expired and wrong-audience tokens are 401 even with a genuine signature.
A multi-tenant server is only as safe as its boundaries: the token decides which tenant's data a call can see and what it may do. Testing them means switching tokens on purpose and checking what changes. This chapter does that against the acme-crm-mcp demo: two tenants, a read-only token on a write, expired and wrong-audience tokens, with the exact claims, queries, headers and audit rows behind each answer.
{ "org_id": "acme",
"scope": "crm:read crm:write",
"client_id": "acme-agent",
"aud": "http://localhost:8787/mcp",
"iss": "http://localhost:8788" }Only org_id and the client differ. Headers apply from the next connection: disconnect and reconnect after switching.
tools/call search_contacts {"query": "Dana"}
{"matches": 0, "contacts": []}Not an empty search: globex's rows don't exist for an acme token.
Switching tenants
export ACME=$(npm run -s token) # acme-agent
export GLOBEX=$(npm run -s token -- globex-agent)
export RO=$(npm run -s token -- acme-readonly)
Decoded, the acme and globex tokens have the same shape, the same audience and the same scopes. Only org_id (and the client) differs, and that's what decides the world you see:
| Token | search_contacts {"query": "Dana"} |
|---|---|
acme-agent (org_id: acme) |
{"matches": 0, "contacts": []} |
globex-agent (org_id: globex) |
{"matches": 1, "contacts": [{"id": "c_201", "name": "Dana Kim", "company": "Northwind", …}]} |
In the Inspector, a changed Authorization header is sent from the next connection on: disconnect and reconnect after every switch, or you're still testing the old token.
Isolation lives in the database
Look at the table three ways:
# as the owner: one table, every tenant's rows
docker compose exec db psql -U postgres -d crm -c "select id, tenant_id, name from contacts order by tenant_id"
# as the app's role, no tenant set: 0 rows
docker compose exec db psql -U app_user -d crm -c "select count(*) from contacts"
# as the app's role, the way the server does it on every call
docker compose exec db psql -U app_user -d crm -c \
"begin; select set_config('app.tenant_id', 'acme', true); select id, name from contacts; commit;"
| Connection | Rows |
|---|---|
postgres (owner) |
5: acme's three, globex's two |
app_user, no tenant set |
0 |
app_user, tenant = acme |
3: Priya, Marcus, Sofia |
app_user, tenant = globex |
2: Dana, Tom |
What makes it hold:
- The server never connects as the owner. Table owners bypass row-level security by default; the demo also
FORCEs it, so even an owner connection would be filtered. - The tenant is set per transaction from the token's
org_id, withset_config(…, true), so it can't leak to the next request on a pooled connection. - No tenant means no rows.
current_setting('app.tenant_id', true)is NULL when unset, and NULL matches nothing. - Writes are checked too (
WITH CHECK): a row can only be inserted with the transaction's tenant.
The filter lives in Postgres, so even a buggy query can't cross tenants.
401 or 403
| Means | In the demo | |
|---|---|---|
| 401 | Who are you? No valid authentication for this resource | Missing, expired, wrong-audience, or forged token |
| 403 | I know who you are, and you're not allowed | A read-only token on a write tool |
The read-only write. With the acme-readonly token, create_contact fails in the Inspector with an authorization error. The server's answer was a 403 whose challenge names the missing scope; the Inspector read that and tried step-up authorization, asking the authorization server for a better token. The demo's authorization server has no sign-in page, so that attempt failed, and that is the error on screen. To see the server's own answer, send the same call yourself:
curl -i -X POST http://localhost:8787/mcp \
-H "Authorization: Bearer $RO" -H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_contact","arguments":{"name":"Dana Kim"}}}'
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="crm:write", resource_metadata="http://localhost:8787/.well-known/oauth-protected-resource"
{"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":"Tool create_contact requires scope crm:write"}}
The scope check runs before the request reaches the MCP tools, so nothing was written.
The audit log keeps the attempt
docker compose exec db psql -U postgres -d crm -c \
"select tenant_id, client_id, tool, arguments, outcome, detail from audit_log"
| tenant_id | client_id | tool | arguments | outcome | detail |
|---|---|---|---|---|---|
| acme | acme-readonly | create_contact | {"name": "Dana Kim"} |
denied | missing scope crm:write |
One row per attempt: the Inspector's try and the curl, two rows. Every write attempt is recorded, including the ones that fail; a read-only integration trying to write is exactly what you want to find later. A request rejected with a 401 isn't audited, because no tenant was established; it's counted in /metrics instead.
Bad tokens
npm run -s token -- acme-agent --expired # expired an hour ago
npm run -s token -- acme-agent --audience https://other-api.example # issued for another server
Both are test-only, minted locally with the demo's signing key, so their signatures are genuine:
| Token | Answer | Message |
|---|---|---|
| Expired | 401, error="invalid_token" |
Invalid token: "exp" claim timestamp check failed |
| Wrong audience | 401, error="invalid_token" |
Invalid token: unexpected "aud" claim value |
A stolen token is only useful until it expires, and a token meant for another API can't be replayed here. A genuine signature isn't enough.
The checklist
- The same call with two tenants' tokens returns different, tenant-scoped data (reconnect between them).
- In Postgres,
app_usersees 0 rows with no tenant and only that tenant's rows with one. - A read-only token on a write: 403
insufficient_scope, the scope named, no row written, an audit row with outcomedenied. - Expired and wrong-audience tokens: 401
invalid_token, for different reasons.
The demo's contract tests check all four automatically (Chapters 2 and 6).
Practice: two boundaries
Model the demo's row-level security (owner vs. app role, FORCE, an unset tenant, WITH CHECK on writes), then read challenges the way a client should: parse a WWW-Authenticate header and decide whether to sign in, get a new token, or ask for a missing scope.
Quiz
1.You switch the Inspector from the acme token to the globex token and repeat the Dana search, but still get 0 matches. What's the likely cause?
2.Connected to Postgres as app_user with no tenant set, SELECT * FROM contacts returns how many rows?
3.Why does the demo FORCE row-level security and connect as a non-owner role?
4.A read-only token calls create_contact. The Inspector shows an authorization error. What did the MCP server actually answer?
5.Where is the denied create_contact recorded?
6.A token is correctly signed by the demo's authorization server, but issued for https://other-api.example. What happens?
Check your understanding
Try each question before opening its answer.
What changes between the acme and globex tokens, and what does that change?
Only org_id (and the client). Same audience, same scopes, same server. That one claim decides which tenant's rows every tool can see: Dana is 0 matches for acme and c_201 for globex.
Why do you have to reconnect after changing the token in the Inspector?
Headers are sent from the next connection on. Without reconnecting, you're still testing the old token.
Why does app_user see 0 rows with no tenant set?
The policy compares tenant_id with current_setting('app.tenant_id', true), which is NULL when unset, and NULL matches no row. A forgotten tenant leaks nothing.
When is 401 different from 403?
401: authentication is missing or invalid (no token, expired, wrong audience, forged). 403: the caller is known but lacks the permission, and an insufficient_scope challenge names the scope it needs.
The Inspector's error and the server's answer disagree. Which do you trust?
Ask the server directly with curl. Here the server answered 403 insufficient_scope; the Inspector's error came from its failed step-up attempt at the authorization server.
Why test a correctly signed token for the wrong audience?
Because the signature only proves who issued it. A token issued for another API must not be replayable against this one, so the audience check has to fail it.
Next up: Chapter 5 breaks the server on purpose: rate limits, bad arguments, and a delete aimed at another tenant.