DEV Community

Cover image for Never Let the Model Pick the Tenant ID: Securing an LLM Agent in Go
Jules Robineau
Jules Robineau

Posted on • Originally published at jrobineau.com

Never Let the Model Pick the Tenant ID: Securing an LLM Agent in Go

TL;DR: an LLM that calls tools is a client you cannot trust. And it holds your production credentials. The most important rule fits in one sentence. Your server decides who the user is, never the model. In this article, I show how I secured a real LLM agent in Go, in production. Everything also applies to MCP servers.

This article is for Go developers who put an LLM agent or an MCP server in production. Not in a demo.

Everyone builds MCP servers. Almost nobody secures them.

MCP stands for Model Context Protocol. It is a standard that lets an AI use your tools: read a file, call an API, query a database. Everyone is adopting it, fast. And building an MCP server in Go is easy. Twenty lines are enough with the official SDK:

package main

import (
    "context"
    "log"

    "github.com/modelcontextprotocol/go-sdk/mcp"
)

type EchoInput struct {
    Message string `json:"message" jsonschema:"the text to echo back"`
}

func echo(ctx context.Context, req *mcp.CallToolRequest, in EchoInput) (*mcp.CallToolResult, any, error) {
    return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: in.Message}}}, nil, nil
}

func main() {
    s := mcp.NewServer(&mcp.Implementation{Name: "echo", Version: "0.1.0"}, nil)
    mcp.AddTool(s, &mcp.Tool{Name: "echo", Description: "Echo a message."}, echo)
    if err := s.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

That part is easy. Now look at what this server really is. It is a bridge between an AI that can be manipulated with text and your real systems. And most of these servers ship with no protection at all. In 2026, researchers scanned the internet. They found about 492 MCP servers open to everyone, with no authentication. These were not demos. They were servers wired to real systems, reachable by anyone.

I do not use MCP myself. I use tool-calling from the Anthropic SDK. It changes nothing: the danger is the same. The model proposes actions. Your code runs them with production access. The rest of this article shows how I keep that from going wrong.

The setup

I built an LLM agent in Go, in production. It is an assistant in a regulated field. It calls tools to read and write real data, on behalf of logged-in users. The stack is standard. Anthropic SDK for the model. Postgres for data. Redis for limits. Keycloak for identity.

Remove the business part, and the same shape as an MCP server remains. A model, tools, your systems. So everything below applies to both.

I come from security, and I am a bit paranoid. Plugging an AI into real user data without hardening it first was a no. And the news keeps proving me right. Anthropic published a study, Agentic Misalignment. It shows something worrying. Under pressure, in test scenarios, top models will leak confidential data once they get tools. Claude included. In the real world, agents have already been tricked. Some dumped a database's secrets. Others sent hidden email copies to an attacker. The problem is not the model. The problem is the unprotected agent you build around it.

The threat model, in plain words

A threat model is the list of what can go wrong. Here, it fits in three sentences. An LLM with tools is an API client. Its inputs can be written by anyone, through the prompt. So treat every tool argument as if an attacker typed it.

This attack has a name: prompt injection. You steer the AI with plain text. It is risk number 1 in the OWASP Top 10 for LLMs. And no architecture removes it completely today.

Identity comes from the server, never from the model

This is rule number 1. Everything else revolves around it.

The model proposes, but never picks who it is

First, a word about tenants. In a multi-client app, a tenant is one client's data space. The tenant_id says who a piece of data belongs to.

Take a tool like get_record(tenant_id, user_id, record_id). When the model calls it, it proposes a tenant_id on its own. If you accept that value, you have a big problem. Anyone can then ask the AI to read another client's data. And this is not theory. In production, a model once sent my server the text your_tenant_id as an identifier. Yes, the literal text. With full confidence. If my server had passed it on, the query would have hit the database with garbage. Or worse, with someone else's identifier.

The fix is simple. Identity and tenant come from the logged-in user's session. The server always overwrites the value proposed by the model. And when the two values differ, it writes a log. A gap here means one thing: hallucination or attack.

// The model proposes arguments. It does NOT get to choose who it acts as.
func (s *Service) prepareToolInput(sess Session, args map[string]any) map[string]any {
    for _, key := range []string{"tenant_id", "user_id"} {
        serverVal := sess.Get(key) // resolved from the validated JWT
        if v, ok := args[key].(string); ok && v != "" && v != serverVal {
            // Divergent value = hallucination or attack. Log it, then enforce.
            s.log.Warn("tool_call."+key+"_mismatch",
                zap.Int("proposed_len", len(v)), zap.String("enforced", serverVal))
        }
        args[key] = serverVal // the server always wins
    }
    return args
}
Enter fullscreen mode Exit fullscreen mode

One nuance, though. The model can safely pick a document ID, as long as that document belongs to the user. For example, for a request like "compare document A and document B". The database checks the tenant anyway, as we will see below. The final rule fits in one sentence. The model picks what. Never who.

Keep the token away from the model

To overwrite with the right value, you need a reliable identity. Mine comes from the JWT. A JWT is a signed token that proves who the user is. My server checks that token at the start of every request. Then the identity travels with the request, into every call: the model, the database, the tools.

Two rules never change. One, service tokens and user tokens stay separate. A user can never pass as a service. Two, if the identity server stops responding, we reject everything. We never allow by default.

What about the token itself? The model never sees it. It moves from Go service to Go service, on the server side. The model only touches data.

Calling an external API is another rule. The server uses its own OAuth credentials. Never the user's token. The MCP spec calls this "no token passthrough". Between my internal services, it is different. The user's token is passed along, then checked again by each service. So every token stays where it was meant to be used. Nowhere else.

Give each agent a short list of tools

An agent does not need all your tools. Give it the full catalog, and a poisoned prompt can reach a sensitive tool. In my system, each agent has its own list of allowed tools. The server looks up the requested tool in that list. If it is not there, it refuses. Before anything runs:

handler, ok := s.toolsFor(agentType)[toolName]
if !ok {
    return toolError("tool %q not available for this agent", toolName) // never execute unknown tools
}
Enter fullscreen mode Exit fullscreen mode

Why one list per agent, and not one big list? Because when an injection succeeds, the attacker gets every tool the current agent can reach. Not one more. A search assistant that can also delete accounts is an accident waiting for its prompt. As for an unknown tool name, it is a bug or an attack. In both cases, we refuse and we write a log.

One last point for MCP. If you load MCP servers built by others, be careful with their tool descriptions. The model reads those descriptions and trusts them. A poisoned description can steer your agent. This is called tool poisoning.

Put tenant isolation in the database

You can filter data by tenant in your Go code. But one forgotten if creates a leak. So I put the barrier in Postgres, not in the code. Postgres has a feature built for this: Row Level Security, or RLS. It decides which rows each query is allowed to see:

ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON conversations
    USING      (tenant_id = current_setting('app.current_tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
Enter fullscreen mode Exit fullscreen mode

On every connection, my code fills the app.current_tenant_id variable. The value comes from the user's token. Then Postgres filters on its own. Even a buggy query cannot read another client's rows. They are invisible. And the application role is not allowed to turn RLS off.

One detail that matters. When a resource is not yours, my API answers "not found". Not "forbidden". Why? "Forbidden" confirms the resource exists. That is a free hint for an attacker testing random IDs. "Not found" gives nothing away.

Mask personal data before the model

PII means personal data: name, email, phone. The model does not need Jane's real name to write her a message. So before every call to the model, my server replaces personal data with neutral placeholders. The real values only come back at display time:

"Email the contract to Jane Doe (jane.doe@acme.com)"
        │  inject
        ▼
"Email the contract to {PH:customer_name} ({PH:email})"   → model sees only tokens
        │  resolve (UI only)
        ▼
"Email the contract to Jane Doe (jane.doe@acme.com)"
Enter fullscreen mode Exit fullscreen mode

Three details make this work. One, I store the masked text and the original text in two separate places. Replaying a conversation never exposes the real values. Two, if a user types a code like {PH:test} in a message, my server escapes it. It will never be mistaken for a real placeholder. Three, when a tool needs a real value, it gets that one. Never the full list. Less data moving around means less risk. And as a bonus, a smaller token bill.

Give the model a budget, and watch it

An agent looping on tools can cost a lot of money, fast. It can also get attacked just to make you pay. So I set limits, in layers:

  • A request limit per IP address, at the front door.
  • A limit per user for expensive tools, checked before every call.
  • A cap on tokens. An oversized request gets trimmed instead of failing.
  • A circuit breaker around the model API. After several failures in a row, it stops the calls for a while. I use the gobreaker library.
  • An alert when a user spends too much in one day. An alert, not a block.

One question remains. What happens when Redis is down and the limit cannot be checked? There are two schools. Fail open: let it pass. Fail closed: block everything. My per-user limiter lets it pass. I prefer a working service over a perfect limit.

The choice depends on what the control is for. Fail closed exists to stop abuse and to fail fast. But applied everywhere, it just moves the outage. One Redis hiccup would kill the whole feature. So I split. Authorization always blocks when in doubt. You never let someone in because the check could not run. The cost limit can let things pass. Other nets stay active: the IP limit, the token cap, the circuit breaker. Without those nets, I would do the opposite. Context decides.

Then, you watch. Every tool call writes a structured log: who, which tool, which decision, how long. Cost metrics go to Prometheus. Per-user detail goes to an audit table. And the most valuable log line is not a success. It is the identity gap from earlier, when the model proposes a wrong tenant_id. That line is your smoke detector for prompt injection.

Harden the code of every tool

An attacker can influence every tool argument. So the code receiving those arguments is a border to defend. The rules are well known, and a bit boring. But they work:

  • Never glue SQL by hand. I use sqlc, which generates typed, parameterized queries. SQL injection becomes impossible.
  • Never use a shell. Arguments go straight to the program, without sh.
  • Limits everywhere: request size, content size, maximum duration.
exec.Command("sh", "-c", "grep "+in.Query+" file")  // ❌ shell injection
exec.Command("grep", in.Query, "file")              // ✅ args, no shell
// SQL: db.QueryContext(ctx, "... WHERE id = $1", id), always parameterized
Enter fullscreen mode Exit fullscreen mode

The tool description guides the model. But your code does the protecting. Validate in the schema. Then validate again in the code.

Protect the code before production

All these protections run in production. But security starts earlier, in the repo. In practice, on my side:

  • gitleaks scans every commit. It blocks secrets, keys, and .env files.
  • Secrets live in a dedicated manager, never in the code.
  • An architecture linter forbids business logic from importing infrastructure.
  • Production builds strip the test data. If it is still there, the program refuses to start.
  • CI runs lint and tests on every push.

What changes for a real MCP server

Everything above applies to any agent. If you build a real MCP server, add these points:

  • Locally, use the stdio transport. For remote, use Streamable HTTP. It replaces the old HTTP+SSE. And as soon as you leave stdio, authentication becomes mandatory.
  • For auth, the spec requires OAuth 2.1 with PKCE. Also check the token's audience (RFC 8707). A token made for another server must not work on yours.
  • Do not expose the server by accident. Listen on 127.0.0.1 by default. Check the Origin and Host headers. And never open the server to the internet without authentication.

The pre-production checklist

Before you put an LLM agent or an MCP server in production, tick every box.

  • [ ] Identity and tenant come from the server, never from the model
  • [ ] Every gap proposed by the model is logged as a possible attack
  • [ ] Each agent has its own tool list, and unknown tools are rejected
  • [ ] The JWT is checked at the front door, and everything is rejected if the identity server is down
  • [ ] Service tokens and user tokens are separate
  • [ ] Tenant isolation lives in the database with RLS, not only in the code
  • [ ] Personal data is masked before it reaches the model
  • [ ] Token cap, rate limits, circuit breaker, and spend alerts in place
  • [ ] Parameterized SQL, no shell, size and time limits everywhere
  • [ ] Every tool call is logged, with an audit table
  • [ ] Remote MCP server: OAuth 2.1 + PKCE, token audience checked, no token passthrough
  • [ ] Server listening locally, Origin and Host headers checked, auth before any exposure
  • [ ] Secrets in a dedicated manager, scanned at every commit

What to remember

People still call these systems chatbots. That is a mistake. And that mistake leads straight to a data leak. An LLM with tools is an API client that writes half of its own requests. Treat it that way. Go is a great fit for this work. And developers who can build these agents and secure them will stay rare for a while.

Security is no longer optional here. It is what separates a useful agent from an agent that leaks your users' data. Are you putting an AI agent or an MCP server into production? Do you want a second pair of eyes on your security? That is exactly what I do. Get in touch. Ship your agent. Just do not ship the breach with it.


Originally published at jrobineau.com.

I'm Jules Robineau, a senior Go backend and DevSecOps freelancer based in Paris. I build and harden production AI/backend systems at scale (25M+ users). CompTIA PenTest+, Top 1% TryHackMe. Services · GitHub · LinkedIn

Sources: MCP Authorization spec, MCP Security Best Practices, Official Go SDK, Anthropic Agentic Misalignment, postmark-mcp (Snyk), Supabase MCP leak (Pomerium), RFC 8707, RFC 9728.

Top comments (0)