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 :
stdioen local, Streamable HTTP à distance (il remplace l'ancien HTTP+SSE, déprécié). Dès que tu quittesstdio, 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êtesOriginetHost(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/Hostvalidé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.