{"slug": "never-let-the-model-pick-the-tenant-id-securing-an-llm-agent-in-go", "title": "Never Let the Model Pick the Tenant ID: Securing an LLM Agent in Go", "summary": "A Go developer building a production LLM agent in a regulated field warns that the most important security rule is that the server, not the model, must determine the user's identity. The developer shows how prompt injection can lead to data leaks if tool arguments like tenant_id are accepted from the model, and notes that in 2026 researchers found about 492 unauthenticated MCP servers exposed online.", "body_md": "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.\n\nThis article is for Go developers who put an LLM agent or an MCP server in production. Not in a demo.\n\nMCP 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:\n\n```\npackage main\n\nimport (\n    \"context\"\n    \"log\"\n\n    \"github.com/modelcontextprotocol/go-sdk/mcp\"\n)\n\ntype EchoInput struct {\n    Message string `json:\"message\" jsonschema:\"the text to echo back\"`\n}\n\nfunc echo(ctx context.Context, req *mcp.CallToolRequest, in EchoInput) (*mcp.CallToolResult, any, error) {\n    return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: in.Message}}}, nil, nil\n}\n\nfunc main() {\n    s := mcp.NewServer(&mcp.Implementation{Name: \"echo\", Version: \"0.1.0\"}, nil)\n    mcp.AddTool(s, &mcp.Tool{Name: \"echo\", Description: \"Echo a message.\"}, echo)\n    if err := s.Run(context.Background(), &mcp.StdioTransport{}); err != nil {\n        log.Fatal(err)\n    }\n}\n```\n\nThat 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.\n\nI 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.\n\nI 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.\n\nRemove the business part, and the same shape as an MCP server remains. A model, tools, your systems. So everything below applies to both.\n\nI 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.\n\nA 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.\n\nThis 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.\n\nThis is rule number 1. Everything else revolves around it.\n\nFirst, a word about tenants. In a multi-client app, a tenant is one client's data space. The `tenant_id`\n\nsays who a piece of data belongs to.\n\nTake a tool like `get_record(tenant_id, user_id, record_id)`\n\n. When the model calls it, it proposes a `tenant_id`\n\non 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`\n\nas 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.\n\nThe 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.\n\n```\n// The model proposes arguments. It does NOT get to choose who it acts as.\nfunc (s *Service) prepareToolInput(sess Session, args map[string]any) map[string]any {\n    for _, key := range []string{\"tenant_id\", \"user_id\"} {\n        serverVal := sess.Get(key) // resolved from the validated JWT\n        if v, ok := args[key].(string); ok && v != \"\" && v != serverVal {\n            // Divergent value = hallucination or attack. Log it, then enforce.\n            s.log.Warn(\"tool_call.\"+key+\"_mismatch\",\n                zap.Int(\"proposed_len\", len(v)), zap.String(\"enforced\", serverVal))\n        }\n        args[key] = serverVal // the server always wins\n    }\n    return args\n}\n```\n\nOne 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.\n\nTo 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.\n\nTwo 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.\n\nWhat 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.\n\nCalling 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.\n\nAn 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:\n\n```\nhandler, ok := s.toolsFor(agentType)[toolName]\nif !ok {\n    return toolError(\"tool %q not available for this agent\", toolName) // never execute unknown tools\n}\n```\n\nWhy 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.\n\nOne 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.\n\nYou can filter data by tenant in your Go code. But one forgotten `if`\n\ncreates 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:\n\n```\nALTER TABLE conversations ENABLE ROW LEVEL SECURITY;\nCREATE POLICY tenant_isolation ON conversations\n    USING      (tenant_id = current_setting('app.current_tenant_id')::uuid)\n    WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);\n```\n\nOn every connection, my code fills the `app.current_tenant_id`\n\nvariable. 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.\n\nOne 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.\n\nPII 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:\n\n```\n\"Email the contract to Jane Doe (jane.doe@acme.com)\"\n        │  inject\n        ▼\n\"Email the contract to {PH:customer_name} ({PH:email})\"   → model sees only tokens\n        │  resolve (UI only)\n        ▼\n\"Email the contract to Jane Doe (jane.doe@acme.com)\"\n```\n\nThree 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}`\n\nin 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.\n\nAn 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:\n\n`gobreaker`\n\nlibrary.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.\n\nThe 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.\n\nThen, 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`\n\n. That line is your smoke detector for prompt injection.\n\nAn 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:\n\n`sqlc`\n\n, which generates typed, parameterized queries. SQL injection becomes impossible.`sh`\n\n.\n\n```\nexec.Command(\"sh\", \"-c\", \"grep \"+in.Query+\" file\")  // ❌ shell injection\nexec.Command(\"grep\", in.Query, \"file\")              // ✅ args, no shell\n// SQL: db.QueryContext(ctx, \"... WHERE id = $1\", id), always parameterized\n```\n\nThe tool description guides the model. But your code does the protecting. Validate in the schema. Then validate again in the code.\n\nAll these protections run in production. But security starts earlier, in the repo. In practice, on my side:\n\n`gitleaks`\n\nscans every commit. It blocks secrets, keys, and `.env`\n\nfiles.Everything above applies to any agent. If you build a real MCP server, add these points:\n\n`stdio`\n\ntransport. For remote, use Streamable HTTP. It replaces the old HTTP+SSE. And as soon as you leave `stdio`\n\n, authentication becomes mandatory.`127.0.0.1`\n\nby default. Check the `Origin`\n\nand `Host`\n\nheaders. And never open the server to the internet without authentication.Before you put an LLM agent or an MCP server in production, tick every box.\n\n`Origin`\n\nand `Host`\n\nheaders checked, auth before any exposurePeople 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.\n\nSecurity 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.](https://jrobineau.com/#contact) Ship your agent. Just do not ship the breach with it.\n\n*Originally published at jrobineau.com.*\n\n*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*\n\n**Sources:** [MCP Authorization spec](https://modelcontextprotocol.io/specification/draft/basic/authorization), [MCP Security Best Practices](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices), [Official Go SDK](https://github.com/modelcontextprotocol/go-sdk), [Anthropic Agentic Misalignment](https://www.anthropic.com/research/agentic-misalignment), [postmark-mcp (Snyk)](https://snyk.io/blog/malicious-mcp-server-on-npm-postmark-mcp-harvests-emails/), [Supabase MCP leak (Pomerium)](https://www.pomerium.com/blog/when-ai-has-root-lessons-from-the-supabase-mcp-data-leak), RFC 8707, RFC 9728.", "url": "https://wpnews.pro/news/never-let-the-model-pick-the-tenant-id-securing-an-llm-agent-in-go", "canonical_source": "https://dev.to/julesrobineau/never-let-the-model-pick-the-tenant-id-securing-an-llm-agent-in-go-o6e", "published_at": "2026-07-22 13:33:57+00:00", "updated_at": "2026-07-22 14:02:36.526003+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-safety", "ai-agents", "developer-tools"], "entities": ["Anthropic", "Keycloak", "Postgres", "Redis", "OWASP"], "alternates": {"html": "https://wpnews.pro/news/never-let-the-model-pick-the-tenant-id-securing-an-llm-agent-in-go", "markdown": "https://wpnews.pro/news/never-let-the-model-pick-the-tenant-id-securing-an-llm-agent-in-go.md", "text": "https://wpnews.pro/news/never-let-the-model-pick-the-tenant-id-securing-an-llm-agent-in-go.txt", "jsonld": "https://wpnews.pro/news/never-let-the-model-pick-the-tenant-id-securing-an-llm-agent-in-go.jsonld"}}