A propos Compétences Expérience Services Blog Contact

Ne laisse jamais le modèle choisir le tenant ID : sécuriser un agent LLM en Go Never Let the Model Pick the Tenant ID: Securing an LLM Agent in Go

TL;DR : un LLM qui peut appeler des outils, c'est un client non fiable et manipulable par prompt, avec tes credentials de prod dans les mains. La règle qui pèse le plus lourd : les paramètres de sécurité (qui est l'utilisateur, à quel tenant il appartient) viennent de la session authentifiée, jamais du modèle. Ce qui suit, c'est un retour de terrain pour sécuriser un agent LLM en Go, tiré d'un service que j'ai vraiment mis en prod, et transposé aux serveurs MCP au passage.

Pour qui : les devs Go qui mettent des agents LLM ou des serveurs MCP en production, pas des démos.

Tout le monde construit des serveurs MCP. Presque personne ne les sécurise.

MCP (Model Context Protocol) est le standard d'intégration qui se répand le plus vite de l'ère IA, le « USB-C des outils IA ». Un serveur MCP minimal en Go, c'est vraiment une vingtaine de lignes avec le SDK officiel :

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)
	}
}

Ça, c'est la partie facile. Le vrai sujet, c'est ce qu'est réellement ce serveur : un pont entre un modèle injectable par prompt et tes vrais systèmes. Et la plupart partent en prod sans le moindre durcissement. En 2026, des chercheurs ont scanné Internet et trouvé environ 492 serveurs MCP grands ouverts, zéro authentification. Pas des POC. Des serveurs branchés sur de vrais systèmes, joignables par n'importe qui.

Que tu utilises MCP ou, comme moi, le tool-calling natif du SDK Anthropic, le threat model est le même : le modèle propose des actions, et quelque chose de ton côté les exécute avec un accès production. Tout ce qui suit, c'est comment j'évite que ça finisse en incident.

Le contexte

J'ai construit un agent LLM en production en Go : un assistant dans un domaine régulé qui appelle des outils (certains locaux, la plupart fédérés en gRPC vers des services voisins) pour lire et écrire de vraies données métier au nom d'utilisateurs authentifiés. SDK Anthropic pour le modèle, Postgres pour l'état, Redis pour les limites, Keycloak pour l'identité.

Retire le domaine et c'est la même forme que n'importe quel serveur MCP : modèle, dispatch d'outils, tes systèmes. Tout ce qui suit se généralise.

Je ne pouvais pas le livrer autrement. Entre une formation en sécurité et une paranoïa que je n'ai pas envie de m'excuser d'avoir, mettre un agent devant de vraies données utilisateur sans le durcir d'abord n'allait jamais arriver. Et 2026 me donne raison chaque mois. La propre recherche Agentic Misalignment d'Anthropic a montré qu'en scénario red-team, sous pression, des modèles de pointe (Claude compris) vont délibérément exfiltrer des données confidentielles dès qu'on leur donne des outils. Dans la nature, on a déjà vu des agents se faire injecter par prompt pour vider les tokens d'une base ou glisser des e-mails en copie cachée vers un attaquant. Le modèle n'est pas la menace. L'agent non durci que tu mets autour, si.

Le threat model, version courte

Le voilà en entier : un LLM à tool-calling est un client d'API dont les entrées sont en partie écrites par quiconque peut influencer le prompt. Donc tu traites chaque argument d'outil comme contrôlé par un attaquant, et chaque claim d'identité venant du modèle comme un faux. Le prompt injection est le risque n°1 du OWASP Top 10 LLM, et ce n'est pas près d'être réglé au niveau de l'architecture.

L'identité, c'est au serveur de la dire, jamais au modèle

C'est la règle pour laquelle je me battrais. Tout le reste, c'est de la défense en profondeur autour d'elle.

Le modèle propose ; il ne décide pas qui il est

Quand le modèle appelle un outil comme get_record(tenant_id, user_id, record_id), il va joyeusement proposer un tenant_id. Laisse-le passer et bravo, tu as construit un outil d'exfiltration cross-tenant piloté en langage naturel. Et ce n'est pas théorique. En prod, un modèle m'a un jour tendu la chaîne littérale your_tenant_id comme identifiant de tenant, avec l'aplomb que seule une IA qui hallucine peut avoir. Transmise, elle aurait frappé la base avec du n'importe quoi, ou pire, la ligne de quelqu'un d'autre.

Donc l'identité et le tenant viennent de la session authentifiée. Le serveur écrase ce que le modèle a fourni et logge la divergence, parce qu'un écart ici, c'est soit une hallucination, soit une sonde :

// 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
}

Il y a une asymétrie qui vaut le coup d'être explicite. Un identifiant de ressource dans le périmètre de l'utilisateur, le modèle peut le choisir sans risque (une requête « compare A et B » est légitime, et la RLS impose le tenant côté données de toute façon). L'identité et le tenant, jamais. La ligne, ce n'est pas « fais confiance au modèle » ou « ne lui fais pas confiance », c'est : le modèle choisit quoi, jamais qui.

Amène l'identité jusque-là, et garde le token loin du modèle

Pour que cet écrasement veuille dire quelque chose, encore faut-il que l'identité soit réelle. La validation JWT est un middleware précoce, pas une arrière-pensée : les tokens sont vérifiés contre le JWKS du fournisseur OIDC, les claims atterrissent dans le contexte de la requête, et ils voyagent avec chaque appel en aval (le client du modèle, la session DB, le dispatcher d'outils). Deux choses sur lesquelles je ne transige pas : les tokens service-account restent distincts des tokens end-user (un claim admin sur un token utilisateur ne peut pas atteindre une API service, donc pas d'escalade silencieuse), et si le fournisseur d'identité est injoignable, le validateur refuse tout au lieu de laisser passer par défaut.

Et le token lui-même ? Le modèle ne le voit jamais. Les credentials circulent côté serveur, de service Go à service Go, dans le contexte de la requête. Le modèle ne manipule que de la donnée. La façon de s'authentifier en aval dépend de la frontière franchie. Pour une API tierce (disons un service de données gouvernemental externe), le serveur utilise ses propres credentials OAuth, jamais le token de l'utilisateur : c'est exactement la règle MCP « no token passthrough ». Entre mes services internes, le JWT de l'utilisateur est propagé et revalidé à chaque hop, chacun appliquant sa propre autorisation et sa RLS. Le token reste dans son audience prévue. C'est de la propagation d'identité, pas le relais « confused deputy » contre lequel MCP met en garde.

Mets tes outils en liste blanche, et rejette l'inconnu

Un agent n'a pas besoin de tous les outils que ta plateforme expose, et lui filer le catalogue complet, c'est comme ça qu'un prompt compromis atteint un truc qu'il n'aurait jamais dû toucher. Les outils sont en liste blanche par agent et par rôle ; le dispatcher cherche l'outil demandé dans le catalogue de ce contexte et rejette tout ce qui est inconnu avant le moindre RPC ou exécution :

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

Pourquoi par contexte et pas juste une grosse liste blanche ? Parce que le rayon d'explosion d'une injection réussie, c'est exactement l'ensemble des outils que l'agent courant peut atteindre. Un assistant « cherche dans la doc » qui a accès à « supprime un utilisateur », c'est un bug qui attend le bon prompt. Un nom d'outil inconnu, lui, c'est soit un bug soit une sonde. Dans les deux cas il ne s'exécute pas, et ça mérite une ligne de log.

Une subtilité propre à MCP : si ton host charge aussi des serveurs MCP tiers, traite leurs descriptions d'outils comme de l'entrée non fiable. Une description hostile, c'est du prompt injection déguisé (« tool poisoning ») : le modèle la lit et lui fait confiance.

Descends l'isolation des tenants dans la base

Le filtrage en couche applicative est à un if oublié d'une fuite, et dans un agent, le if oublié peut être celui que le modèle t'a convaincu d'oublier. Donc la frontière des tenants vit dans Postgres, pas dans mon code Go :

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);

La variable de session est posée par connexion depuis le claim tenant du JWT. Le rôle applicatif ne peut pas contourner la RLS ; un rôle admin séparé avec BYPASSRLS n'existe que pour une poignée de lectures cross-tenant, via son propre pool. Les lignes des autres tenants sont invisibles même pour une requête qui, sinon, matcherait.

Un choix délibéré : sur un mismatch de propriété, l'API renvoie « not found », pas « forbidden ». « Forbidden » confirme que la ressource existe, ce qui est un indice gratuit pour quiconque énumère des IDs. C'est un détail qui ne coûte rien et ferme une vraie fuite d'information.

Masque les PII avant qu'elles n'atteignent le modèle

Le modèle n'a pas besoin du vrai nom de Jane Doe pour lui rédiger un message. Donc avant que la requête ne quitte mon process, les PII sont remplacées par des tokens opaques, et seulement résolues à la frontière de l'UI :

"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)"

Quelques détails qui font tenir le truc en vrai. Le texte assaini et l'original sont stockés séparément, pour qu'un replay multi-tour ne ré-injecte jamais les vraies valeurs. Le texte utilisateur qui contient littéralement la syntaxe de token est échappé d'abord (quelqu'un finira par taper {PH:whatever} dans un message, et tu ne veux pas que ce soit confondu avec un vrai token). Et quand un outil a réellement besoin d'une vraie valeur, seuls les champs référencés sont injectés, jamais toute la map de PII. Moins de PII sur le fil, c'est un rayon d'explosion plus petit et une facture de tokens plus légère.

Budgète le modèle, et surveille ce qu'il fait

Une boucle d'agent avec outils est un moyen rapide de cramer du budget ou de te faire DoS, parfois les deux. Donc le modèle tourne avec un budget, et les contrôles sont empilés :

  • Rate limit HTTP par IP (Redis GCRA) au bord.
  • Limites par utilisateur et par opération pour les outils coûteux, vérifiées avant le dispatch.
  • Un cap de tokens dur qui clampe silencieusement les requêtes trop grosses au lieu de les faire échouer.
  • Un circuit breaker (j'utilise gobreaker) autour de l'API du modèle : après N échecs consécutifs, il ouvre pour un cooldown et échoue vite plutôt que de marteler un provider dégradé.
  • Un seuil de dépense quotidien par utilisateur qui émet une alerte. De l'observabilité, pas un blocage dur.

Un arbitrage que j'assume : mon limiteur par utilisateur fail open si Redis tombe. Disponibilité plutôt qu'enforcement strict, et oui, c'est un choix.

Parce que ce débat n'en finit pas : il n'y a pas de réponse universelle, ça dépend de ce à quoi sert le contrôle. Le fail-closed existe pour stopper l'abus et pour échouer vite, histoire de ne pas continuer dans un état à moitié cassé et non vérifié. Appliqué aveuglément, il ne fait que déplacer la panne : fail-closed sur une dépendance fragile, c'est un hoquet de Redis qui emporte toute la fonctionnalité. Donc je découpe par contexte. L'autorisation fail closed : tu ne laisses jamais entrer quelqu'un parce que le check n'a pas pu tourner. Le limiteur de coût fail open, parce qu'un « allow » accordé à tort y est déjà encadré par la limite par IP, le cap de tokens et le circuit breaker. Retire ces filets et j'inverse. C'est le contexte qui décide, pas le dogme.

Et tu surveilles. Chaque appel d'outil laisse une ligne de log structurée (qui, quel outil, la décision, la latence), et les métriques tokens / coût / durée partent vers Prometheus, labellisées par modèle et par agent mais jamais par utilisateur, parce que les labels par utilisateur explosent la cardinalité (ce détail-là vit dans une table d'audit). Le log qui compte vraiment n'est pas un succès. C'est le mismatch d'identité de tout à l'heure : « model tried to override tenant_id ». C'est ton détecteur de fumée pour le prompt injection.

Chaque handler d'outil est un puits d'injection

Chaque argument d'outil est influençable par un attaquant, donc chaque handler est une frontière hostile. Les règles sont les règles ennuyeuses, et elles sont ennuyeuses parce qu'elles marchent :

  • Pas de SQL construit par concaténation. Je génère des requêtes paramétrées avec sqlc, donc la couche requête est typée et impossible à bricoler en injection.
  • Pas de sh -c. Les args passent directement au binaire, jamais par un shell.
  • Borne tout : limites de taille de body, caps de payload (les gros binaires retombent proprement sur du texte), timeouts.
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

La description de l'outil est un indice pour le modèle ; le handler est la frontière. Valide dans le schéma, puis revalide dans le handler.

Shifte à gauche

Rien de tout ça ne survit à un repo en vrac, donc les contrôles runtime sont adossés à des contrôles au commit. gitleaks et des hooks pre-commit (lefthook) bloquent les secrets, les .env, les clés et les fichiers trop gros avant qu'ils n'atterrissent. Le linting d'architecture (depguard) empêche la logique métier d'importer l'infrastructure. Les secrets vivent dans un gestionnaire (Infisical), jamais dans le code. Et les builds de prod retirent les fixtures de test et paniquent si elles sont malgré tout présentes, pour qu'une réponse de test déterministe ne parte jamais vers un vrai utilisateur. La CI lance lint et tests avec race detector, sur des permissions minimales.

Ce qui change si c'est un vrai serveur MCP

Tout ce qui précède est indépendant du transport. Si tu construis vraiment un serveur MCP, voilà ce qui lui est spécifique :

  • Transport : stdio en local, Streamable HTTP à distance (il remplace l'ancien HTTP+SSE, déprécié). Dès que tu quittes stdio, il faut de l'auth.
  • Auth : OAuth 2.1 avec PKCE, le serveur jouant le rôle de resource server. Valide l'audience du token (RFC 8707) pour qu'un token émis pour un autre serveur ne passe pas chez toi, et publie les Protected Resource Metadata (RFC 9728).
  • Ne l'expose pas par accident : binde sur 127.0.0.1, valide les en-têtes Origin et Host (DNS rebinding), et exige de l'auth avant qu'il ne fasse face à Internet.

La checklist de durcissement (copier-coller)

Ne mets un agent LLM ou un serveur MCP en production que quand toutes les cases sont cochées.

  • Identité et tenant injectés côté serveur depuis le token validé, jamais depuis le modèle
  • Tentatives d'override des paramètres de sécu loggées comme signaux d'attaque
  • Allow-list d'outils par contexte ; outils inconnus rejetés avant dispatch
  • JWT/OIDC validé au bord, claims propagés partout, fail closed
  • Tokens service-account vs utilisateur distingués (pas d'escalade)
  • Isolation tenant au niveau DB (RLS + variable de session) ; le rôle applicatif ne peut pas BYPASSRLS
  • PII masquées avant le modèle, résolues uniquement à l'UI, outils avec le minimum de données réelles
  • Caps de tokens, rate limits en couches, circuit breaker, alertes de dépense (fail-open/closed décidé consciemment)
  • SQL paramétré, pas de shell, bornes de taille et de temps sur chaque handler
  • Logs d'appels d'outils structurés et métriques agrégées (pas de cardinalité par utilisateur) + table d'audit
  • Non-stdio/remote : OAuth 2.1 + PKCE, audience validée, no token passthrough
  • Bindé sur localhost, Origin/Host validés (anti DNS-rebinding), auth exigée avant toute exposition Internet
  • Secrets dans un gestionnaire dédié, jamais en dur, scannés au commit (gitleaks)

On récapitule

Les gens continuent d'appeler ces trucs des chatbots. C'est le mauvais modèle mental, et le mauvais modèle mental, c'est comme ça qu'on se fait breacher. Un LLM à tool-calling est un client d'API qui écrit la moitié de ses propres requêtes, alors traite-le comme tel. Go est excellent pour ça (binaire unique, typage fort, concurrence facile), et les devs capables de construire ces agents et de les sécuriser vont rester rares un bon moment.

Ici, la sécurité a cessé d'être un « nice-to-have ». C'est la ligne entre un agent qui aide tes utilisateurs et un agent qui fuit discrètement leurs données, et les incidents ci-dessus montrent de quel côté ça penche quand personne ne regarde. Si tu mets un agent IA ou un serveur MCP en production et que tu veux un second regard (paranoïaque) sur l'architecture de sécurité, c'est exactement ce sur quoi j'accompagne les équipes. Écris-moi. Livre l'agent. Ne livre juste pas la faille avec.

TL;DR: an LLM that can call tools is an untrusted, promptable client holding your production credentials. The rule that carries the most weight: security-relevant parameters (who the user is, which tenant they belong to) come from the authenticated session, never from the model. What follows is a field guide to securing an LLM agent in Go, pulled from a service I actually shipped, and mapped onto MCP servers along the way.

Written for Go engineers shipping LLM agents or MCP servers to production, not demos.

Everyone is building MCP servers. Almost nobody is securing them.

MCP (Model Context Protocol) is the fastest-spreading integration standard of the AI era, the "USB-C for AI tools." A minimal MCP server in Go is genuinely about twenty lines 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)
	}
}

That's the easy part. The real question is what this server actually is: a bridge between a prompt-injectable model and your real systems. And most of them ship with no hardening at all. In 2026, researchers scanned the internet and found around 492 MCP servers wide open, zero authentication. Not proof-of-concepts. Servers wired into real systems, reachable by anyone.

Whether you use MCP or, like me, the Anthropic SDK's native tool-calling, the threat model is the same: the model proposes actions, and something on your side runs them with production access. Everything below is how I keep that from turning into an incident.

The setup

I built a production LLM agent in Go: an assistant in a regulated domain that calls tools (some local, most federated over gRPC to sibling services) to read and write real business data for authenticated users. Anthropic SDK for the model, Postgres for state, Redis for limits, Keycloak for identity.

Strip the domain away and it's the same shape as any MCP server: model, tool dispatch, your systems. Everything here generalizes.

I couldn't have shipped it any other way. Between a security background and a paranoid streak I'm not apologizing for, putting an agent in front of real user data without hardening it first was never going to happen. And 2026 keeps proving the instinct right. Anthropic's own Agentic Misalignment research showed that under pressure, in red-team scenarios, frontier models (Claude included) will deliberately leak confidential data once you hand them tools. In the wild, we've already watched agents get prompt-injected into dumping a database's tokens and quietly BCC-ing emails to an attacker. The model isn't the threat. The unguarded agent you wrap around it is.

The threat model, short version

Here it is in full: a tool-calling LLM is an API client whose inputs are partly written by whoever can influence the prompt. So treat every tool argument as attacker-controlled, and every identity claim coming from the model as a forgery. Prompt injection is OWASP's #1 LLM risk, and it isn't getting solved at the architecture level any time soon.

Identity is the server's job, never the model's

This is the one I'd fight for. Everything else is defense in depth around it.

The model proposes; it doesn't get to say who it is

When the model calls a tool like get_record(tenant_id, user_id, record_id), it will happily propose a tenant_id. Pass that through and congratulations, you've built a cross-tenant exfiltration tool with a natural-language interface. And it isn't theoretical. In production, a model once handed my server the literal string your_tenant_id as a tenant identifier, with all the confidence only a hallucinating LLM can muster. Forwarded, it would have hit the database with garbage, or worse, someone else's row.

So identity and tenancy come from the authenticated session. The server overwrites whatever the model supplied and logs the divergence, because a mismatch here is either a hallucination or a probe:

// 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
}

There's an asymmetry worth being explicit about. A record ID inside the user's own scope is fine for the model to choose (a "compare A and B" request is legitimate, and RLS enforces tenancy at the data layer anyway). Identity and tenancy never are. The line isn't "trust the model" or "don't." It's: the model picks what, never who.

Get the identity there, and keep the token away from the model

For that overwrite to mean anything, the identity has to be real. JWT validation is early middleware, not an afterthought: tokens are checked against the OIDC provider's JWKS, the claims land in the request context, and they ride along to every downstream call (the model client, the DB session, the tool dispatcher). Two things I don't compromise on: service-account tokens stay distinct from end-user tokens (an admin claim on a user token can't reach a service API, so there's no silent escalation), and if the identity provider is unreachable, the validator rejects everything instead of quietly waving requests through.

And the token itself? The model never sees it. Credentials flow server-side, Go service to Go service, in the request context. The model only ever handles data. How the server authenticates downstream depends on the boundary. For a third-party API (say an external government data service), the server uses its own OAuth client credentials, never the user's token: that's exactly MCP's "no token passthrough" rule. Between my own internal services, the user's JWT is propagated and re-validated at every hop, each applying its own authorization and RLS. The token stays inside its intended audience. That's identity propagation, not the confused-deputy relay MCP warns about.

Allow-list your tools, and reject the unknown

An agent doesn't need every tool your platform exposes, and handing it the full catalog is how a compromised prompt reaches something it was never meant to touch. Tools are whitelisted per agent and role; the dispatcher looks up the requested tool in that context's catalog and rejects anything unknown before any RPC or execution:

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

Why per-context and not one big allow-list? Because the blast radius of a successful injection is exactly the set of tools the current agent can reach. A "search the docs" assistant with access to "delete user" is a bug waiting for the right prompt. An unknown tool name, meanwhile, is either a bug or a probe. Either way it never runs, and it's worth a log line.

One MCP-specific wrinkle: if your host also loads third-party MCP servers, treat their tool descriptions as untrusted input. A hostile description is prompt injection with extra steps ("tool poisoning"): the model reads it and trusts it.

Push tenant isolation down to the database

App-layer filtering is one forgotten if away from a leak, and in an agent, the forgotten if might be one the model talked you into. So the tenant boundary lives in Postgres, not in my Go code:

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);

The session variable is set per connection from the JWT's tenant claim. The application role can't bypass RLS; a separate admin role with BYPASSRLS exists only for a handful of cross-tenant reads, through its own pool. Rows from other tenants are invisible even to a query that would otherwise match them.

One deliberate detail: on an ownership mismatch, the API returns "not found," not "forbidden." "Forbidden" confirms the resource exists, which is a free hint for anyone enumerating IDs. It costs nothing and closes a real information leak.

Mask PII before it reaches the model

The model doesn't need Jane Doe's real name to draft her a message. So before the request leaves my process, PII is swapped for opaque tokens, and only resolved back at the UI boundary:

"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)"

A few things make this hold up in practice. The sanitized text and the original are stored separately, so replaying a multi-turn conversation never re-injects the real values. User input that literally contains the token syntax gets escaped first (someone will eventually type {PH:whatever} into a message, and you don't want that mistaken for a real token). And when a tool genuinely needs a real value, only the referenced fields get injected, never the whole PII map. Less PII on the wire means a smaller blast radius and a smaller token bill.

Budget the model, and watch what it does

An agent loop with tools is a fast way to burn budget or get yourself DoS-ed, sometimes both. So the model runs on a budget, with the controls layered:

  • Per-IP HTTP rate limit (Redis GCRA) at the edge.
  • Per-user, per-operation limits for expensive tools, checked before dispatch.
  • A hard token cap that silently clamps oversized requests instead of failing them.
  • A circuit breaker (I use gobreaker) around the model API: after N consecutive failures it opens for a cooldown and fails fast instead of hammering a degraded provider.
  • A daily spend threshold per user that emits an alert. Observability, not a hard block.

One tradeoff I'll defend: my per-user limiter fails open if Redis is down. Availability over strict enforcement, and yes, that's a choice.

Because this gets argued to death: there's no universal answer, it depends on what the control is for. Fail-closed exists to stop abuse and to fail fast, so you're not operating in a half-broken, unverified state. Applied blindly, though, it just moves the outage: fail-closed on a fragile dependency means a Redis blip takes the whole feature down with it. So I split it by context. Authorization fails closed: you never let someone in because the check couldn't run. The cost limiter fails open, because a wrong "allow" there is already boxed in by the per-IP limit, the token cap, and the circuit breaker. Take those backstops away and I'd flip it. Context decides, not dogma.

And you watch. Every tool call leaves a structured log line (who, which tool, the decision, the latency), and the token, cost, and duration metrics go to Prometheus, labeled by model and agent but never by user, because per-user labels blow up cardinality (that detail lives in an audit table). The single most useful line in all of it isn't a success. It's the identity mismatch from earlier: "model tried to override tenant_id." That's your smoke alarm for prompt injection.

Every tool handler is an injection sink

Every tool argument is attacker-influenceable, so every handler is a hostile boundary. The rules are the boring ones, and they're boring because they work:

  • No string-built SQL. I generate parameterized queries with sqlc, so the query layer is type-safe and can't be hand-edited into an injection.
  • No sh -c. Args go straight to the binary, never through a shell.
  • Bound everything: body-size limits, payload caps (large binaries fall back to text gracefully), timeouts.
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

The tool description is a hint for the model; the handler is the boundary. Validate in the schema, then validate again in the handler.

Shift it left

None of this survives contact with a messy repo, so the runtime controls are backed by commit-time ones. gitleaks and pre-commit hooks (lefthook) block secrets, .env files, keys, and oversized blobs before they land. Architecture linting (depguard) stops business logic from importing infrastructure. Secrets live in a manager (Infisical), never in the code. And production builds strip the test fixtures and panic if they're somehow present, so a deterministic test response can never ship to a real user. CI runs lint and race-enabled tests on least-privilege permissions.

What changes if it's an actual MCP server

Everything so far is transport-agnostic. If you're building an actual MCP server, here's what's specific to it:

  • Transport: stdio locally, Streamable HTTP for remote (it replaced the deprecated HTTP+SSE). The moment you leave stdio, you need auth.
  • Auth: OAuth 2.1 with PKCE, the server acting as a resource server. Validate the token's audience (RFC 8707) so a token minted for another server can't be replayed against yours, and publish Protected Resource Metadata (RFC 9728).
  • Don't expose it by accident: bind to 127.0.0.1, validate the Origin and Host headers (DNS rebinding), and require auth before it ever faces the internet.

The copy-paste hardening checklist

Ship an LLM agent or MCP server to production only when every box is ticked.

  • Identity and tenancy injected server-side from the validated token, never from the model
  • Security-param override attempts logged as attack signals
  • Per-context tool allow-list; unknown tools rejected before dispatch
  • JWT/OIDC validated at the edge, claims propagated everywhere, fail closed
  • Service-account vs user tokens distinguished (no privilege escalation)
  • DB-level tenant isolation (RLS + session var); app role can't BYPASSRLS
  • PII masked before the model, resolved only at the UI, tools get minimal real data
  • Token caps, layered rate limits, circuit breaker, spend alerts (fail-open/closed decided consciously)
  • Parameterized SQL, no shell, size and time bounds on every handler
  • Structured tool-call logs and aggregated metrics (no per-user cardinality) + audit table
  • Non-stdio/remote: OAuth 2.1 + PKCE, audience validated, no token passthrough
  • Bound to localhost, Origin/Host validated (DNS-rebinding), auth required before any internet exposure
  • Secrets in a dedicated manager, never in code, scanned at commit time (gitleaks)

Wrapping up

People keep calling these things chatbots. It's the wrong mental model, and the wrong mental model is how you end up breached. A tool-calling LLM is an API client that writes half its own requests, so treat it like one. Go is a great fit for the job (single binary, strong typing, easy concurrency), and engineers who can build these agents and secure them are going to stay rare for a while.

Security here stopped being a nice-to-have. It's the line between an agent that helps your users and one that quietly leaks their data, and the incidents above show which way it tips when nobody's watching. If you're putting an AI agent or MCP server into production and want a second, paranoid set of eyes on the architecture, that's exactly what I help teams with. Get in touch. Ship the agent. Just don't ship the breach with it.