Every check passed. Valid token, right scope, 200 OK. And Maya from Acme was reading Globex's deployments.
A story: the names and numbers are invented, the failure is real.
I shipped my MCP server on a Friday. Bearer token on /mcp, a scope check, and I tested it both ways: the right token worked, a missing token got a 401. Green. I went home early.
On Monday, Maya from Acme support sent me a screenshot. Her assistant had shown her deployments for payments-globex. That is a Globex service. Maya works for Acme.
I rebuilt what happened from the logs:
- Maya's assistant connected with a valid token that carried the
mcp:devopsscope. - She asked, "What shipped to payments last night?" The model called
list_recent_deploymentswithservice: "payments". Fine. - Later, a pasted ticket mentioned
payments-globex. The model called the tool with that name. - The endpoint saw a valid token with the right scope and let it through.
Valid token. Right scope. 200 OK. Every check had passed, and the wrong customer's data still came out. I stared at that for an hour.
When I'm stuck, I open the one reference that does not hand out answers. AI Wisdom is not a friendly tutor. It is a guru, and it makes you earn the solution.
I had no answer.
I sat with that one for a while.
Draw the problem
Once I could say it in one sentence, I drew it. First the version I would have understood in my first year. Then the one for people who read tokens for a living.
Beginner view: a hotel key card. The token is a key card. Door one asks, "is this a real card?" Mine passed. Door two asks, "does this card open this room?" I never built door two.
%%{init: {"themeVariables": {"fontSize": "16px", "lineColor": "#8B97A8"}, "flowchart": {"wrappingWidth": 180, "nodeSpacing": 20, "rankSpacing": 32, "padding": 10, "subGraphTitleMargin": {"top": 6, "bottom": 20}}}}%%
flowchart TD
M(["Maya, Acme support"]):::person -->|shows her assistant's key card| D1
subgraph D1["Door 1: is this a real card?"]
direction TB
A["Valid token, scope mcp:devops"]:::ok --> B["The server says YES"]:::ok
end
subgraph D2["Door 2: does it open THIS room?"]
direction TB
C["The room is payments-globex, a Globex service. Nobody built this door."]:::bad --> D["Globex's deployments"]:::bad
end
D1 --> D2
D2 -->|shown to| M2(["Maya, an Acme user"]):::person
classDef ok fill:#DCFCE7,stroke:#16A34A,color:#14532D
classDef bad fill:#FEE2E2,stroke:#DC2626,color:#7F1D1D
classDef note fill:#E0E7FF,stroke:#4F46E5,color:#1E1B4B
classDef person fill:#FCE7F3,stroke:#DB2777,color:#831843
style D1 fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569
style D2 fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569
Technical view: the same Monday, as requests. The model chose the service name both times. The endpoint only sees the token, and the token was fine.
%%{init: {"themeVariables": {"fontSize": "16px", "lineColor": "#8B97A8"}, "flowchart": {"wrappingWidth": 180, "nodeSpacing": 20, "rankSpacing": 32, "padding": 10, "subGraphTitleMargin": {"top": 6, "bottom": 20}}}}%%
flowchart TD
M(["Maya, Acme support"]):::person -->|asks her assistant| R1
subgraph R1["Request 1: payments"]
direction TB
a1["Endpoint: token valid, scope OK"]:::ok -->|tool runs| a2["Acme's deployments"]:::ok
end
R1 --> P["A pasted ticket mentions payments-globex.<br/>The model calls again, same token."]:::note
P --> R2
subgraph R2["Request 2: payments-globex"]
direction TB
b1["Endpoint: token valid, scope OK"]:::ok -->|tool runs| b2["Nobody asks: may THIS caller read it?"]:::bad
b2 --> b3["Globex's deployments"]:::bad
end
R2 -->|reach| M2(["Maya, an Acme user"]):::person
classDef ok fill:#DCFCE7,stroke:#16A34A,color:#14532D
classDef bad fill:#FEE2E2,stroke:#DC2626,color:#7F1D1D
classDef note fill:#E0E7FF,stroke:#4F46E5,color:#1E1B4B
classDef person fill:#FCE7F3,stroke:#DB2777,color:#831843
style R1 fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569
style R2 fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569
Do you have this problem?
- A tool takes a
service,tenant,projectoridargument and passes it straight to a backend - The only authorization code sits on the endpoint (
RequireAuthorization), and none inside the tools - You have never run a test where a valid token asks for someone else's resource
- Some tools are "just read-only", so nobody looked at what they can leak
I ticked three. How many do you tick?
Why the demo never showed it
The fix that is a trap
I never trust a tenant identifier that came from the model now. It is not permission.
The fix: check inside the tool
Look at the first line of the method body:
[McpServerTool(Name = "list_recent_deployments")]
[Description("List recent deployments for a service")]
public async Task<string> ListDeployments(
[Description("Service name")] string service,
[Description("Number of deployments to return")] int count = 5)
{
await callerAccess.RequireServicePermissionAsync(service, "deployments:read");
var response = await _http.GetAsync(
$"/api/deployments?service={Uri.EscapeDataString(service)}&top={count}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
callerAccess is built from validated claims for HTTP, and from an explicit local policy for stdio. It never trusts a tenant or service name supplied by the model.
The fix as a flow. The check moves from the endpoint into the tool. It uses the caller from the validated identity, not anything the model wrote.
%%{init: {"themeVariables": {"fontSize": "16px", "lineColor": "#8B97A8"}, "flowchart": {"wrappingWidth": 150, "nodeSpacing": 20, "rankSpacing": 32, "padding": 10, "subGraphTitleMargin": {"top": 6, "bottom": 20}}}}%%
flowchart TD
R(["Request: token + a service name chosen by the model"]):::note --> EP
subgraph EP["Endpoint"]
direction TB
E["Token valid? Scope present?"]
end
subgraph TL["Tool, first line"]
direction TB
T["May THIS caller read this service?<br/>deployments:read"]:::note
end
EP -->|no| X1["401 or 403"]:::stop
EP -->|yes| TL
TL -->|"may NOT"| X2["Refused: backend untouched"]:::stop
TL -->|"may"| B["Backend returns the rows"]:::ok
classDef ok fill:#DCFCE7,stroke:#16A34A,color:#14532D
classDef bad fill:#FEE2E2,stroke:#DC2626,color:#7F1D1D
classDef stop fill:#FEF3C7,stroke:#D97706,color:#78350F
classDef note fill:#E0E7FF,stroke:#4F46E5,color:#1E1B4B
style EP fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569
style TL fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569
Prove it with six tests
I wrote these before I touched the second tool:
- No token
- A token from the wrong issuer
- A token for the wrong audience
- An expired token
- A valid token with a missing scope
- A valid token asking for another tenant's resource
Which layer stops which test? The first five are about the token. Only the tool knows whose data a valid token is asking for.
%%{init: {"themeVariables": {"fontSize": "16px", "lineColor": "#8B97A8"}, "flowchart": {"wrappingWidth": 190, "nodeSpacing": 20, "rankSpacing": 32, "padding": 10, "subGraphTitleMargin": {"top": 6, "bottom": 20}}}}%%
flowchart TD
subgraph EP["The endpoint stops these five"]
direction TB
E["1. No token<br/>2. Wrong issuer<br/>3. Wrong audience<br/>4. Expired token<br/>5. Valid token, missing scope"]:::ok
end
subgraph TL["Only the tool can stop this one"]
direction TB
T["6. Valid token, another tenant's resource"]:::bad
end
EP --> TL
classDef ok fill:#DCFCE7,stroke:#16A34A,color:#14532D
classDef bad fill:#FEE2E2,stroke:#DC2626,color:#7F1D1D
classDef stop fill:#FEF3C7,stroke:#D97706,color:#78350F
classDef note fill:#E0E7FF,stroke:#4F46E5,color:#1E1B4B
style EP fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569
style TL fill:none,stroke:#A3AEC0,stroke-dasharray:5 4,color:#475569
On the sixth, my old server answered instead of refusing. If the sixth request gets an answer, you have a backdoor with a nice schema.
The guru's last word
Go deeper
My problem is fixed. The rest of running an MCP server in production is in the full guide:
- The complete five-step .NET build: stdio, tools, VS Code
mcp.json, and Streamable HTTP with JWT bearer auth - What the 2026-07-28 revision removed (the
initializehandshake andMcp-Session-Id), so any replica can answer a request - Routing and rate limiting by tool with the new
Mcp-Nameheader - Long-running tools that survive dropped connections and redeploys
- A completion checklist to run before you hand the server to another team
More from AI Wisdom
Read the full MCP guide on AI Wisdom →
Neighbouring problems, in the same library:
- Securing Agents: The OWASP Agentic Top 10 in Practice: least agency, per-agent identity and policy gates in code
- Prompt Injection: Attack Vectors and Defence in Production: direct and indirect injection, and a defence-in-depth architecture
- Guardrails: Input/Output Safety in Production: input, output, schema, tool and cost guardrails, layered
- Production AI System Checklist: a free 6-page PDF with 30 checks
Which Amit are you? Open your MCP server, find a tool that takes a service or tenant argument, and look at where the caller's access to that value is checked. Reply with where yours is: "endpoint only", "inside the tool", or "nowhere I can find".









Top comments (1)
Deriving the tenant from the token on the server, never from a tool argument, closes most of this, since the model can pass any id it saw earlier.