ealeng
Menu

Episode 04 of 7

Test authorization and tenant isolation

Same server, same tools, different tokens: switch tenants, look at isolation inside Postgres, read the 403 a read-only token gets and the audit row it leaves, and check that expired and wrong-audience tokens get a 401.

MCP Server · Hands-on MCP, Ep. 26 min read
Video transcript3:53

0:00Intro

0:01

Hands-on MCP, episode two: tokens and tenants, live.

0:05

Same server, same tools, different tokens. Let’s see how different the world looks.

0:11Where’s Dana?

0:12

Last time, Becky searched for Dana Kim with Acme’s token, and got zero matches.

0:18

But Dana does exist. She’s a contact of Globex, another company on the same server. To test isolation, Becky’s team registered a test client for Globex, too.Becky: She’s in the system. I’ve seen her! 🤔

0:2901 · A Globex token

0:30

Becky asks the authorization server for a Globex token.

0:34

Decoded, it’s the same shape as before: same server, same scopes. Only the org ID differs. Globex.

0:4202 · Switching tenants

0:44

She swaps the Authorization header for the Globex token. Headers are sent from the next connection on, so she disconnects, and connects again.

0:52

Same tool, same query: Dana.Becky: There she is! 😮

0:55

There she is. Dana Kim, contact c 201, at Northwind. Same server, same tool, same question. The token decides which world you see.

1:0503 · Isolation lives in the database

1:06

Where does that isolation come from? Becky looks in the database. As the owner, she sees one contacts table, holding both tenants.

1:15

But the server never connects as the owner. It connects as app user. And app user, with no tenant set, sees nothing at all. Zero rows.

1:24

On every call, the server sets the tenant from the token’s org ID, inside the transaction. Now app user sees Acme’s contacts, and only Acme’s.

1:34

That’s row-level security. The filter lives in Postgres, so even a buggy query can’t cross tenants.Becky: So even a bug can’t leak it. Nice. 😌

1:4104 · A read-only token

1:42

Next, scopes. Becky’s team also registered a read-only client for Acme: it may read contacts, never change them.

1:50

She switches the Inspector to the read-only token, and reconnects.

1:54

Headers are sent from the next connection on, so she disconnects, and connects again, now as the read-only client.

2:0204 · A read-only token

2:03

Now a write. Create contact, Dana Kim, execute. Tool call failed.Becky: Tool Call Failed… with an auth error? 🤨

2:08

The network log shows what really happened. The server answered 403: valid token, wrong scope. Create contact needs crm:write.

2:18

A 403 like this also tells the client which scope is missing, so the Inspector tried to get a better token, called step-up authorization. This demo’s authorization server has no sign-in page, so that attempt fails, and that’s the error on screen.

2:3405 · The server’s answer, and the audit log

2:35

Becky sends the same call herself with curl. 403 Forbidden. The WWW-Authenticate header says exactly what’s wrong: insufficient scope, and the scope needed: crm:write.

2:49

Compare that with 401. 401 means: who are you? 403 means: I know who you are, and you’re not allowed to do this.

2:57

And the attempt didn’t vanish. The audit log has both denied writes: tenant, client, tool, outcome, and why. Every write attempt is recorded, including the ones that fail.

3:0806 · Bad tokens

3:10

Last test: bad tokens. These are test-only tokens, minted locally with the demo signing key. First, one that expired an hour ago.

3:19

401, invalid token: the expiry check failed. A stolen token is only useful until it expires.

3:27

Next, a perfectly valid token, issued for a different server.

3:31

Also 401: unexpected audience. A token meant for another API can’t be replayed here, even though the signature is genuine.

3:40Summary

3:42

Tenant. Scope. Audit. Expiry.

3:44

Next time, Becky breaks things on purpose: rate limits, bad arguments, and a delete aimed at another tenant.Becky: Next: let’s break it! 😈

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.

Try it · Same call, different tokenThe demo's tenants, database and denials
{ "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, with set_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

  1. The same call with two tenants' tokens returns different, tenant-scoped data (reconnect between them).
  2. In Postgres, app_user sees 0 rows with no tenant and only that tenant's rows with one.
  3. A read-only token on a write: 403 insufficient_scope, the scope named, no row written, an audit row with outcome denied.
  4. 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.

Try it · Practice in PythonRuns in your browser · ⌘/Ctrl + Enter to run
auth_practice.py54 lines

Quiz

Try it · Check your understanding0 of 6 answered
  1. 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. 2.Connected to Postgres as app_user with no tenant set, SELECT * FROM contacts returns how many rows?

  3. 3.Why does the demo FORCE row-level security and connect as a non-owner role?

  4. 4.A read-only token calls create_contact. The Inspector shows an authorization error. What did the MCP server actually answer?

  5. 5.Where is the denied create_contact recorded?

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

Learning something useful?

New labs, articles, and videos on evaluating and testing AI systems come out regularly. Follow along so you don't miss the next one.